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 <file>", "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 <file>", "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 <file>", "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 <file>", "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 <file> | 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 <file> | 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 <file> + * CLI to normalize existing routes: camel validate normalize <file> */ 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
