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 44a856230dec8637a5d4bb910477164f7cb50f62 Author: Luigi De Masi <[email protected]> AuthorDate: Wed Jun 17 13:52:45 2026 +0200 CAMEL-23775: Add YAML deserializer resolver provider SPI Introduce a YamlDeserializerResolverProvider SPI so runtimes can control how YAML deserializer resolvers are discovered. The default provider preserves the existing classpath service discovery behavior, while runtimes such as Camel Quarkus or Spring Boot can install a CamelContext plugin to provide precomputed resolvers or avoid runtime classpath scanning. Also document the provider hook and add tests covering provider-based discovery, provider replacement of default classpath discovery, and registry resolver discovery with a custom provider installed. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../ROOT/pages/camel-4x-upgrade-guide-4_21.adoc | 4 +- .../camel-yaml-dsl-validator-maven-plugin.adoc | 8 +- .../DefaultYamlDeserializerResolverProvider.java | 141 +++++++ .../yaml/common/YamlDeserializationContext.java | 217 ++++------ .../dsl/yaml/common/YamlDeserializerResolver.java | 6 + .../common/YamlDeserializerResolverProvider.java | 45 ++ .../camel-yaml-dsl-validator-maven-plugin.adoc | 8 +- .../dsl/yaml/validator/YamlValidatorTest.java | 7 + .../camel-yaml-dsl/src/main/docs/yaml-dsl.adoc | 42 +- .../YamlDeserializerResolverDiscoveryTest.groovy | 460 ++++++++++++++++++++- 10 files changed, 786 insertions(+), 152 deletions(-) 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 cf0840c2d10c..38da59bba86d 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 validate 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 CLI. 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. @@ -365,7 +365,7 @@ to contribute custom YAML route step names automatically when the module is on t 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. +See xref:components:others:yaml-dsl.adoc[YAML DSL] for details. === camel-a2a (new, preview) 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 f235056945cb..f1cbe3439110 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 @@ -6,12 +6,12 @@ The Camel YAML DSL Validator Maven Plugin supports the following goals == camel-yaml-dsl-validator:validate -For validating the YAML routes for syntax errors according to the spec. +For validating YAML routes against the generated YAML DSL JSON Schema. -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. +`validate` goal does not accept module-provided custom YAML step names. The Maven plugin does not run Camel's YAML route +loader and does not load custom resolvers. 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. diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/DefaultYamlDeserializerResolverProvider.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/DefaultYamlDeserializerResolverProvider.java new file mode 100644 index 000000000000..cf55c4bd6343 --- /dev/null +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/DefaultYamlDeserializerResolverProvider.java @@ -0,0 +1,141 @@ +/* + * 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.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.Enumeration; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.CamelContext; +import org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException; + +/** + * Default YAML deserializer resolver provider that discovers resolver class names from the classpath. + */ +public class DefaultYamlDeserializerResolverProvider implements YamlDeserializerResolverProvider { + + @Override + public Map<String, YamlDeserializerResolver> findResolvers(CamelContext camelContext) { + Map<String, YamlDeserializerResolver> resolvers = new LinkedHashMap<>(); + for (String resolverClassName : findResolverClassNames(camelContext)) { + resolvers.put(resolverClassName, newResolver(camelContext, resolverClassName)); + } + return resolvers; + } + + private YamlDeserializerResolver newResolver(CamelContext camelContext, String resolverClassName) { + try { + Class<YamlDeserializerResolver> resolverType + = camelContext.getClassResolver().resolveMandatoryClass(resolverClassName, + YamlDeserializerResolver.class); + return camelContext.getInjector().newInstance(resolverType, false); + } catch (Exception e) { + String message = "Error loading YAML deserializer resolver " + resolverClassName + " from " + + YamlDeserializerResolver.RESOURCE_PATH; + throw new YamlDeserializationException(message, e); + } + } + + private List<String> findResolverClassNames(CamelContext camelContext) { + Set<String> resolverClassNames = new LinkedHashSet<>(); + + for (URL resolverResource : findResolverResources(camelContext)) { + resolverClassNames.addAll(readResolverClassNames(resolverResource)); + } + + List<String> sortedResolverClassNames = new ArrayList<>(resolverClassNames); + sortedResolverClassNames.sort(String::compareTo); + return sortedResolverClassNames; + } + + private List<String> readResolverClassNames(URL resolverResource) { + List<String> resolverClassNames = new ArrayList<>(); + + try (BufferedReader reader + = new BufferedReader(new InputStreamReader(resolverResource.openStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String resolverClassName = parseResolverClassName(line); + if (!resolverClassName.isEmpty()) { + resolverClassNames.add(resolverClassName); + } + } + } catch (IOException e) { + throw new YamlDeserializationException( + "Error reading YAML deserializer resolver resource from " + + YamlDeserializerResolver.RESOURCE_PATH + " (" + + safeResourceDescription(resolverResource) + ")", + e); + } + + return resolverClassNames; + } + + private static String safeResourceDescription(URL resolverResource) { + if (resolverResource == null || resolverResource.getProtocol() == null) { + return "unknown resource"; + } + return resolverResource.getProtocol() + " resource"; + } + + private static String parseResolverClassName(String line) { + int comment = line.indexOf('#'); + String uncommentedLine = comment != -1 ? line.substring(0, comment) : line; + return uncommentedLine.trim(); + } + + private Set<URL> findResolverResources(CamelContext camelContext) { + Set<URL> resolverResources = new LinkedHashSet<>(); + + try { + addResolverResources(resolverResources, + camelContext.getClassResolver().loadAllResourcesAsURL(YamlDeserializerResolver.RESOURCE_PATH)); + addResolverResources(resolverResources, camelContext.getApplicationContextClassLoader()); + for (ClassLoader classLoader : camelContext.getClassResolver().getClassLoaders()) { + addResolverResources(resolverResources, classLoader); + } + } catch (IOException e) { + throw new YamlDeserializationException( + "Error locating YAML deserializer resolvers from " + YamlDeserializerResolver.RESOURCE_PATH, e); + } + + return resolverResources; + } + + private static void addResolverResources(Set<URL> resolverResources, ClassLoader classLoader) throws IOException { + if (classLoader != null) { + addResolverResources(resolverResources, classLoader.getResources(YamlDeserializerResolver.RESOURCE_PATH)); + } + } + + private static void addResolverResources(Set<URL> resolverResources, Enumeration<URL> resources) { + if (resources != null) { + while (resources.hasMoreElements()) { + resolverResources.add(resources.nextElement()); + } + } + } +} 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 f51ce2f2c4b3..cb95ebf8e3b7 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,22 +16,13 @@ */ 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; @@ -151,119 +142,59 @@ public class YamlDeserializationContext extends StandardConstructor implements C return; } - addResolverEntries(loadResolversFromClasspath()); - addResolverEntries(loadResolversFromRegistry()); + List<ResolverEntry> resolverEntries = new ArrayList<>(); + resolverEntries.addAll(loadResolversFromProvider()); + resolverEntries.addAll(loadResolversFromRegistry()); + + addResolverEntries(resolverEntries); resolversLoaded = true; } @Override public void stop() { this.constructors.clear(); - this.resolvers.removeIf(entry -> entry.source == ResolverSource.CLASSPATH || entry.source == ResolverSource.REGISTRY); + this.resolvers.removeIf(entry -> entry.source == ResolverSource.PROVIDER || entry.source == ResolverSource.REGISTRY); this.resolversLoaded = false; } - private List<ResolverEntry> loadResolversFromClasspath() { - List<String> resolverClassNames; - resolverClassNames = findResolverClassNames(); + private List<ResolverEntry> loadResolversFromProvider() { + YamlDeserializerResolverProvider provider = getResolverProvider(); + return loadResolverEntries(provider.findResolvers(getCamelContext()), ResolverSource.PROVIDER); + } - 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 YamlDeserializerResolverProvider getResolverProvider() { + YamlDeserializerResolverProvider provider = getCamelContext().getCamelContextExtension() + .getContextPlugin(YamlDeserializerResolverProvider.class); + return provider != null ? provider : new DefaultYamlDeserializerResolverProvider(); } 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; + return loadResolverEntries(getCamelContext().getRegistry().findByTypeWithName(YamlDeserializerResolver.class), + ResolverSource.REGISTRY); } - 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; + private List<ResolverEntry> loadResolverEntries( + Map<String, YamlDeserializerResolver> resolvers, ResolverSource source) { + if (resolvers == null) { throw new YamlDeserializationException( - message, - e); + "YAML deserializer resolver " + source + " returned a null resolver map"); } - } - 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) { + List<ResolverEntry> resolverEntries = new ArrayList<>(resolvers.size()); + for (Map.Entry<String, YamlDeserializerResolver> entry : resolvers.entrySet()) { + if (entry.getKey() == null) { throw new YamlDeserializationException( - "Error reading YAML deserializer resolver resource " + url + " from " - + YamlDeserializerResolver.RESOURCE_PATH, - e); + "YAML deserializer resolver " + source + " returned a null resolver name"); } - } - - 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()); + YamlDeserializerResolver resolver = entry.getValue(); + if (resolver == null) { + throw new YamlDeserializationException( + "YAML deserializer resolver " + source + " returned a null resolver for " + entry.getKey()); } + CamelContextAware.trySetCamelContext(resolver, getCamelContext()); + resolverEntries.add(new ResolverEntry(resolver, source, entry.getKey())); } + return resolverEntries; } private void addResolver(YamlDeserializerResolver resolver, ResolverSource source, String name) { @@ -282,36 +213,36 @@ public class YamlDeserializationContext extends StandardConstructor implements C this.resolvers.sort(this::compareResolverEntries); } - private int compareResolverEntries(ResolverEntry entry1, ResolverEntry entry2) { - int result = compareBuiltInBoundary(entry1, entry2); + private int compareResolverEntries(ResolverEntry first, ResolverEntry second) { + int result = compareBuiltInBoundary(first, second); if (result != 0) { return result; } - result = OrderedComparator.get().compare(entry1.resolver, entry2.resolver); + result = OrderedComparator.get().compare(first.resolver, second.resolver); if (result != 0) { return result; } - result = Integer.compare(entry1.source.rank, entry2.source.rank); + result = Integer.compare(first.source.rank, second.source.rank); if (result != 0) { return result; } - result = entry1.resolver.getClass().getName().compareTo(entry2.resolver.getClass().getName()); + result = first.resolver.getClass().getName().compareTo(second.resolver.getClass().getName()); if (result != 0) { return result; } - return entry1.name.compareTo(entry2.name); + return first.name.compareTo(second.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; + private int compareBuiltInBoundary(ResolverEntry first, ResolverEntry second) { + if (first.isBuiltIn() && !second.isBuiltIn()) { + return second.overridesBuiltIns() ? 1 : -1; } - if (entry1.source != ResolverSource.BUILT_IN && entry2.source == ResolverSource.BUILT_IN) { - return entry1.resolver.getOrder() < YamlDeserializerResolver.ORDER_DEFAULT ? -1 : 1; + if (!first.isBuiltIn() && second.isBuiltIn()) { + return first.overridesBuiltIns() ? -1 : 1; } return 0; } @@ -326,13 +257,21 @@ public class YamlDeserializationContext extends StandardConstructor implements C this.source = source; this.name = name != null ? name : resolver.getClass().getName(); } + + private boolean isBuiltIn() { + return source == ResolverSource.BUILT_IN; + } + + private boolean overridesBuiltIns() { + return resolver.getOrder() < YamlDeserializerResolver.ORDER_DEFAULT; + } } private enum ResolverSource { BUILT_IN(0), MANUAL(1), REGISTRY(2), - CLASSPATH(3); + PROVIDER(3); private final int rank; @@ -387,8 +326,8 @@ public class YamlDeserializationContext extends StandardConstructor implements C YamlDeserializationContext.class.getName(), YamlDeserializationContext.this); - final ConstructNode answer = resolve(n, type.getName()); - return answer.construct(newNode); + final ConstructNode constructor = resolve(n, type.getName()); + return construct(newNode, type.getName(), constructor); }, camelContext); } @@ -420,7 +359,7 @@ public class YamlDeserializationContext extends StandardConstructor implements C } final String id = ((ScalarNode) key).getValue(); - final ConstructNode answer = resolve(node, id); + final ConstructNode constructor = resolve(node, id); return CamelContextAware.trySetCamelContext( (Node n) -> { @@ -429,28 +368,46 @@ public class YamlDeserializationContext extends StandardConstructor implements C YamlDeserializationContext.class.getName(), YamlDeserializationContext.this); - return answer.construct( - ((MappingNode) n).getValue().get(0).getValueNode()); + Node valueNode = ((MappingNode) n).getValue().get(0).getValueNode(); + return construct(valueNode, id, constructor); }, camelContext); } public ConstructNode resolve(Node node, String id) { - return constructors.computeIfAbsent(id, (String s) -> { - ConstructNode answer = null; - - for (ResolverEntry entry : resolvers) { - answer = entry.resolver.resolve(id); - if (answer != null) { - break; - } - } + return constructors.computeIfAbsent(id, nodeId -> resolveConstructor(node, nodeId)); + } - if (answer == null) { - throw new UnknownNodeIdException(node, id); + private ConstructNode resolveConstructor(Node node, String id) { + for (ResolverEntry entry : resolvers) { + ConstructNode constructor; + try { + constructor = entry.resolver.resolve(id); + } catch (YamlDeserializationException e) { + throw e; + } catch (RuntimeException e) { + throw new YamlDeserializationException( + node, + "Error resolving YAML node id: " + id + " using YAML deserializer resolver " + entry.name, + e); + } + if (constructor != null) { + return constructor; } + } + + throw new UnknownNodeIdException(node, id); + } - return answer; - }); + private Object construct(Node node, String id, ConstructNode constructor) { + try { + return constructor.construct(node); + } catch (DuplicateKeyException | UnknownNodeIdException e) { + throw e; + } catch (YamlDeserializationException e) { + throw e; + } catch (RuntimeException e) { + throw new YamlDeserializationException(node, "Error constructing YAML node id: " + id, e); + } } } 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 a9955e79ea5a..415db8cdeecb 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 @@ -19,6 +19,12 @@ package org.apache.camel.dsl.yaml.common; import org.apache.camel.Ordered; import org.snakeyaml.engine.v2.api.ConstructNode; +/** + * Resolves YAML node ids to SnakeYAML constructors. + * <p/> + * Optional Camel modules should implement this SPI to contribute custom YAML route steps. Runtime integrations that + * need to control how resolver implementations are discovered can provide a {@link YamlDeserializerResolverProvider}. + */ public interface YamlDeserializerResolver extends Ordered { /** diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerResolverProvider.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerResolverProvider.java new file mode 100644 index 000000000000..3b494291ef07 --- /dev/null +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerResolverProvider.java @@ -0,0 +1,45 @@ +/* + * 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.common; + +import java.util.Map; + +import org.apache.camel.CamelContext; + +/** + * Strategy for discovering automatically available {@link YamlDeserializerResolver} instances. + * <p/> + * The default implementation discovers resolver class names from {@link YamlDeserializerResolver#RESOURCE_PATH}. + * Runtimes that perform build-time discovery can register their own provider as a Camel context plugin and return the + * resolvers without scanning classpath resources at runtime. + */ +public interface YamlDeserializerResolverProvider { + + /** + * Finds automatically available YAML deserializer resolvers. + * <p/> + * The {@code camelContext} argument is never {@code null}. Implementations must return a non-{@code null} map. Use + * {@code Collections.emptyMap()} when no resolvers are available. Map keys must be stable, non-{@code null} names + * used for diagnostics and deterministic ordering. Map values must be non-{@code null} + * {@link YamlDeserializerResolver} instances. Discovery failures should be reported by throwing a runtime + * exception. + * + * @param camelContext the Camel context used for resolver discovery and instantiation + * @return discovered resolvers keyed by a stable name used for diagnostics and deterministic ordering + */ + Map<String, YamlDeserializerResolver> findResolvers(CamelContext camelContext); +} 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 f235056945cb..f1cbe3439110 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 @@ -6,12 +6,12 @@ The Camel YAML DSL Validator Maven Plugin supports the following goals == camel-yaml-dsl-validator:validate -For validating the YAML routes for syntax errors according to the spec. +For validating YAML routes against the generated YAML DSL JSON Schema. -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. +`validate` goal does not accept module-provided custom YAML step names. The Maven plugin does not run Camel's YAML route +loader and does not load custom resolvers. 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. diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorTest.java index 5605c07f1814..49b2a0966a12 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorTest.java @@ -57,4 +57,11 @@ public class YamlValidatorTest { Assertions.assertEquals(1, report.size()); Assertions.assertTrue(report.get(0).getMessage().contains("setCheese")); } + + @Test + public void testValidateRuntimeCustomStepRejectedBySchema() throws Exception { + var report = validator.validate(new File("src/test/resources/custom-parser-step.yaml")); + Assertions.assertFalse(report.isEmpty()); + Assertions.assertTrue(report.stream().anyMatch(error -> error.getMessage().contains("parserStep"))); + } } 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 df4ed3a30ff0..828ea2e286cd 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 @@ -130,6 +130,11 @@ 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: +This SPI only controls YAML deserialization. It does not add route execution behavior. +The returned object must be a Camel route model definition that Camel can already execute through existing model reifier +or `ProcessorFactory` support, or the optional module must provide that runtime support using the normal Camel extension +mechanisms. + [source,java] ---- import org.apache.camel.dsl.yaml.common.YamlDeserializerBase; @@ -195,16 +200,21 @@ This allows YAML such as: 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 +Resolvers are discovered when YAML routes are parsed. Camel starts with the built-in YAML DSL resolvers, then adds the +resolvers returned by the active `YamlDeserializerResolverProvider` and any `YamlDeserializerResolver` beans found in +the Camel registry. +When no custom provider is registered as a Camel context plugin, the default provider discovers resolver class names from +`META-INF/services/org/apache/camel/YamlDeserializerResolver`. +It looks for resolver resources through the Camel class resolver, the application context classloader, and any classloaders registered with the Camel class resolver. +Runtime integrations that perform build-time discovery, such as native-image runtimes, can register their own +`YamlDeserializerResolverProvider` as a Camel context plugin to supply the automatically available resolvers instead of +using the default runtime classpath resource scanning provider. 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 +Built-in YAML DSL resolvers win over provider-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. +For external resolvers with the same order, registry-provided resolvers are tried before provider-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. @@ -214,8 +224,14 @@ 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. +Resolver classes found through the default provider are instantiated through Camel's injector. +If a provider-discovered or registry-provided resolver implements `CamelContextAware`, Camel sets the current +`CamelContext` before the resolver is used. +Resolver discovery and resolver execution are fail-fast: a broken service entry, provider error, or resolver exception +aborts YAML route loading instead of being silently skipped. +Resolver implementations should be lightweight and should not rely on Camel `Service` lifecycle callbacks from the YAML +deserialization context. Registry resolvers are owned by the registry, and custom providers own the lifecycle of any +resolver instances they return. 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. @@ -224,11 +240,17 @@ When a custom step maps to a model definition that supports child outputs, the d 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. +unaffected. If the service file names a resolver class that cannot be loaded, YAML route parsing fails when resolver +discovery runs. 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. +This also applies to IDE validation and schema-based tooling, including the YAML DSL validator Maven plugin. +Runtime route-loading parsers can use custom resolvers that are available on their classpath, but schema validation +remains limited to the generated built-in DSL schema. +Modules that provide custom YAML steps should include route-loading tests that put the resolver service resource on the +test classpath, load a YAML route using the custom step, and assert that Camel created the expected model definition or +that the route executes as expected. For output-aware custom steps, include at least one nested `steps` entry. 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. 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 index 84058e7620b4..c31d5f6cd166 100644 --- 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 @@ -16,9 +16,13 @@ */ package org.apache.camel.dsl.yaml +import org.apache.camel.CamelContextAware +import org.apache.camel.CamelContext 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.YamlDeserializerResolverProvider +import org.apache.camel.dsl.yaml.common.exception.DuplicateKeyException 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 @@ -27,12 +31,13 @@ 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 +import static org.apache.camel.dsl.yaml.common.YamlDeserializerSupport.asText + class YamlDeserializerResolverDiscoveryTest extends YamlTestSupport { def "discover custom route step resolver from classpath"() { @@ -264,6 +269,168 @@ class YamlDeserializerResolverDiscoveryTest extends YamlTestSupport { classLoader?.close() } + def "class resolver classloader resolver resource is discovered"() { + given: + def classLoader = resolverResourceClassLoader(ClassResolverStepResolver.name) + context.classResolver.addClassLoader(classLoader) + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - classResolverStep: {} + ''' + + then: + with(context.routeDefinitions[0].outputs[0], StepDefinition) { + id == 'class-resolver-step' + } + + cleanup: + classLoader?.close() + } + + def "provider discovered resolver receives camel context"() { + given: + context.getCamelContextExtension().addContextPlugin(YamlDeserializerResolverProvider.class, + new StaticResolverProvider('contextAwareStepResolver', new ContextAwareStepResolver(context))) + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - contextAwareStep: {} + ''' + + then: + with(context.routeDefinitions[0].outputs[0], StepDefinition) { + id == 'context-aware-step' + } + } + + def "context plugin provider contributes custom route step"() { + given: + context.getCamelContextExtension().addContextPlugin(YamlDeserializerResolverProvider.class, + new StaticResolverProvider('providerCustomStepResolver', + new FixedStepResolver('providerCustomStep', 'provider-custom-step', + YamlDeserializerResolver.ORDER_DEFAULT + 1))) + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - providerCustomStep: {} + ''' + + then: + with(context.routeDefinitions[0].outputs[0], StepDefinition) { + id == 'provider-custom-step' + } + } + + def "context plugin provider replaces default classpath discovery"() { + given: + context.getCamelContextExtension().addContextPlugin(YamlDeserializerResolverProvider.class, + new StaticResolverProvider([:])) + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - customStep: {} + ''' + + then: + def e = thrown(UnknownNodeIdException) + e.message.contains('Unknown node id: customStep') + } + + def "registry resolver still loads when context plugin provider is installed"() { + given: + context.getCamelContextExtension().addContextPlugin(YamlDeserializerResolverProvider.class, + new StaticResolverProvider([:])) + 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 "provider null resolver map is rejected with diagnostic"() { + given: + context.getCamelContextExtension().addContextPlugin(YamlDeserializerResolverProvider.class, + new NullMapResolverProvider()) + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - to: + uri: "mock:result" + ''' + + then: + def e = thrown(YamlDeserializationException) + e.message.contains('PROVIDER') + e.message.contains('null resolver map') + } + + def "provider null resolver name is rejected with diagnostic"() { + given: + context.getCamelContextExtension().addContextPlugin(YamlDeserializerResolverProvider.class, + new StaticResolverProvider([(null): new FixedStepResolver('badStep', 'bad-step', + YamlDeserializerResolver.ORDER_DEFAULT)])) + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - to: + uri: "mock:result" + ''' + + then: + def e = thrown(YamlDeserializationException) + e.message.contains('PROVIDER') + e.message.contains('null resolver name') + } + + def "provider null resolver value is rejected with diagnostic"() { + given: + context.getCamelContextExtension().addContextPlugin(YamlDeserializerResolverProvider.class, + new StaticResolverProvider(['nullResolver': null])) + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - to: + uri: "mock:result" + ''' + + then: + def e = thrown(YamlDeserializationException) + e.message.contains('PROVIDER') + e.message.contains('null resolver for nullResolver') + } + def "kamelet route template discovers custom route step"() { when: loadKamelets ''' @@ -297,6 +464,55 @@ class YamlDeserializerResolverDiscoveryTest extends YamlTestSupport { } } + def "regular route template discovers custom route step"() { + when: + loadRoutesNoValidate ''' + - routeTemplate: + id: "customTemplate" + from: + uri: "direct:{{name}}" + steps: + - customStep: + id: "template-custom-step" + steps: + - to: + uri: "mock:result" + ''' + + then: + context.routeTemplateDefinitions.size() == 1 + with(context.routeTemplateDefinitions[0].route.outputs[0], StepDefinition) { + id == 'template-custom-step' + with(outputs[0], ToDefinition) { + endpointUri == 'mock:result' + } + } + } + + def "route configuration discovers custom route step"() { + when: + loadRoutesNoValidate ''' + - routeConfiguration: + onCompletion: + - onCompletion: + steps: + - customStep: + id: "route-configuration-custom-step" + steps: + - to: + uri: "mock:on-completion" + ''' + + then: + context.getRouteConfigurationDefinitions().size() == 1 + with(context.getRouteConfigurationDefinitions().get(0).getOnCompletions().get(0).outputs[0], StepDefinition) { + id == 'route-configuration-custom-step' + with(outputs[0], ToDefinition) { + endpointUri == 'mock:on-completion' + } + } + } + def "unknown custom step reports node id"() { when: loadRoutesNoValidate ''' @@ -311,6 +527,107 @@ class YamlDeserializerResolverDiscoveryTest extends YamlTestSupport { e.message.contains('Unknown node id: missingOptionalStep') } + def "custom route step with duplicate sibling keys reports duplicate key"() { + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - customStep: {} + to: + uri: "mock:result" + ''' + + then: + thrown(DuplicateKeyException) + } + + def "malformed custom route step scalar shape reports custom node id"() { + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - customStep: "not-a-map" + ''' + + then: + def e = thrown(YamlDeserializationException) + e.message.contains('Error constructing YAML node id: customStep') + e.cause instanceof UnsupportedOperationException + } + + def "malformed custom route step id shape reports custom node id"() { + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - customStep: + id: + - bad + ''' + + then: + def e = thrown(YamlDeserializationException) + e.message.contains('Error constructing YAML node id: customStep') + } + + def "malformed custom route step steps shape reports expected sequence"() { + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - customStep: + steps: "not-a-list" + ''' + + then: + def e = thrown(YamlDeserializationException) + e.message.contains('Error constructing YAML node id: customStep') + e.cause.message.contains('expected array') + } + + def "resolver failure reports resolver and node id"() { + given: + context.getCamelContextExtension().addContextPlugin(YamlDeserializerResolverProvider.class, + new StaticResolverProvider('throwingStepResolver', new ThrowingStepResolver())) + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - failingStep: {} + ''' + + then: + def e = thrown(YamlDeserializationException) + e.message.contains('Error resolving YAML node id: failingStep') + e.message.contains('throwingStepResolver') + e.cause instanceof IllegalStateException + } + + def "custom step constructor failure reports custom node id"() { + given: + context.getCamelContextExtension().addContextPlugin(YamlDeserializerResolverProvider.class, + new StaticResolverProvider('failingConstructorStepResolver', new FailingConstructorStepResolver())) + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - constructFailureStep: {} + ''' + + then: + def e = thrown(YamlDeserializationException) + e.message.contains('Error constructing YAML node id: constructFailureStep') + e.cause instanceof IllegalStateException + } + def "broken resolver service entry reports provider class"() { given: def classLoader = resolverResourceClassLoader('com.acme.camel.MissingYamlStepResolver') @@ -334,6 +651,30 @@ class YamlDeserializerResolverDiscoveryTest extends YamlTestSupport { classLoader?.close() } + def "unreadable resolver resource reports sanitized resource location"() { + given: + context.applicationContextClassLoader = unreadableResolverResourceClassLoader() + + when: + loadRoutesNoValidate ''' + - from: + uri: "direct:start" + steps: + - to: + uri: "mock:result" + ''' + + then: + def e = thrown(YamlDeserializationException) + e.message.contains(YamlDeserializerResolver.RESOURCE_PATH) + e.message.contains('resolver resource') + !e.message.contains('user:secret') + !e.message.contains('internal.example.local') + !e.message.contains('very-secret-path') + !e.message.contains('token=') + !e.message.contains('fragment') + } + private static URLClassLoader resolverResourceClassLoader(String resolverLine) { Path root = Files.createTempDirectory('yaml-resolver-provider') Path serviceDirectory = root.resolve('META-INF/services/org/apache/camel') @@ -345,6 +686,36 @@ class YamlDeserializerResolverDiscoveryTest extends YamlTestSupport { return new URLClassLoader([root.toUri().toURL()] as URL[], Thread.currentThread().contextClassLoader) } + private static ClassLoader unreadableResolverResourceClassLoader() { + URL resolverResource = new URL(null, + 'resolver://user:[email protected]/very-secret-path?token=abc#fragment', + new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL url) { + return new URLConnection(url) { + @Override + void connect() { + } + + @Override + InputStream getInputStream() { + throw new IOException('cannot read resolver resource') + } + } + } + }) + + return new ClassLoader(Thread.currentThread().contextClassLoader) { + @Override + Enumeration<URL> getResources(String name) throws IOException { + if (name == YamlDeserializerResolver.RESOURCE_PATH) { + return Collections.enumeration([resolverResource]) + } + return super.getResources(name) + } + } + } + static class CustomStepResolver implements YamlDeserializerResolver { @Override int getOrder() { @@ -394,7 +765,7 @@ class YamlDeserializerResolverDiscoveryTest extends YamlTestSupport { protected boolean setProperty(StepDefinition target, String propertyKey, String propertyName, Node value) { switch (propertyKey) { case 'id': - target.setId(((ScalarNode)value).value) + target.setId(asText(value)) break case 'steps': setSteps(target, value) @@ -493,4 +864,89 @@ class YamlDeserializerResolverDiscoveryTest extends YamlTestSupport { super('applicationStep', 'application-context-step', YamlDeserializerResolver.ORDER_DEFAULT + 1) } } + + static class ClassResolverStepResolver extends FixedStepResolver { + ClassResolverStepResolver() { + super('classResolverStep', 'class-resolver-step', YamlDeserializerResolver.ORDER_DEFAULT + 1) + } + } + + static class ContextAwareStepResolver extends FixedStepResolver implements CamelContextAware { + private final CamelContext expectedCamelContext + private CamelContext camelContext + + ContextAwareStepResolver(CamelContext expectedCamelContext) { + super('contextAwareStep', 'context-aware-step', YamlDeserializerResolver.ORDER_DEFAULT + 1) + this.expectedCamelContext = expectedCamelContext + } + + @Override + CamelContext getCamelContext() { + return camelContext + } + + @Override + void setCamelContext(CamelContext camelContext) { + this.camelContext = camelContext + } + + @Override + ConstructNode resolve(String id) { + if (camelContext.is(expectedCamelContext)) { + return super.resolve(id) + } + return null + } + } + + static class ThrowingStepResolver implements YamlDeserializerResolver { + @Override + ConstructNode resolve(String id) { + if (id == 'failingStep') { + throw new IllegalStateException('resolver boom') + } + return null + } + } + + static class FailingConstructorStepResolver implements YamlDeserializerResolver { + @Override + ConstructNode resolve(String id) { + if (id == 'constructFailureStep') { + return new FailingConstructor() + } + return null + } + } + + static class FailingConstructor implements ConstructNode { + @Override + Object construct(Node node) { + throw new IllegalStateException('constructor boom') + } + } + + static class NullMapResolverProvider implements YamlDeserializerResolverProvider { + @Override + Map<String, YamlDeserializerResolver> findResolvers(CamelContext camelContext) { + return null + } + } + + static class StaticResolverProvider implements YamlDeserializerResolverProvider { + private final Map<String, YamlDeserializerResolver> resolvers + + StaticResolverProvider(String name, YamlDeserializerResolver resolver) { + this([(name): resolver]) + } + + StaticResolverProvider(Map<String, YamlDeserializerResolver> resolvers) { + this.resolvers = resolvers + } + + @Override + Map<String, YamlDeserializerResolver> findResolvers(CamelContext camelContext) { + return resolvers + } + } }
