This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-24709 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 174946ab38d872493ad04c75f572573c3e697a33 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Sep 15 10:15:27 2026 +0200 CAMEL-24709: camel-yaml-dsl - class not found hint for beans uses the bean metadata on the classpath The YAML beans hint from CAMEL-24702 only knew the aggregation strategies in org.apache.camel.processor.aggregate. It now reads the bean metadata the build generates into every JAR (META-INF/services/org/apache/camel/bean.properties and bean/<Name>.json), so any built-in bean written with the wrong package or no package gets "did you mean <javaType> (<interface>)? write: type: <javaType>". The same metadata backs the reifiers: a #class: aggregation strategy, repository or other typed lookup whose class does not exist now fails with NoSuchBeanException that names the built-in bean that was likely meant, or lists the built-in beans of the expected interface, instead of a bare "Cannot find AggregationStrategy in Registry" or "No bean could be found ... of type". - camel-support: PojoBeanHelper scans bean.properties / bean/*.json on the error path (findAll, findByName, beansOfInterface, classNotFoundHint) - camel-api: NoSuchBeanException(name, type, hint) constructor - camel-core-reifier: AbstractReifier.lookupByName(name, expectedType) and mandatoryLookup add the hint; the four aggregation strategy lookups use it - camel-yaml-dsl: BeansDeserializer.classNotFoundHint is data driven Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../java/org/apache/camel/NoSuchBeanException.java | 12 + .../org/apache/camel/reifier/AbstractReifier.java | 22 +- .../org/apache/camel/reifier/MulticastReifier.java | 2 +- .../org/apache/camel/reifier/ProcessorReifier.java | 2 +- .../apache/camel/reifier/RecipientListReifier.java | 2 +- .../org/apache/camel/reifier/SplitReifier.java | 2 +- .../AggregationStrategyClassNotFoundHintTest.java | 113 ++++++++++ .../apache/camel/support/PojoBeanHelperTest.java | 131 +++++++++++ .../org/apache/camel/support/EndpointHelper.java | 4 +- .../org/apache/camel/support/PojoBeanHelper.java | 243 +++++++++++++++++++++ .../dsl/yaml/deserializers/BeansDeserializer.java | 39 ++-- .../org/apache/camel/dsl/yaml/AggregateTest.groovy | 45 ++++ .../org/apache/camel/dsl/yaml/BeansTest.groovy | 56 +++++ 13 files changed, 645 insertions(+), 28 deletions(-) diff --git a/core/camel-api/src/main/java/org/apache/camel/NoSuchBeanException.java b/core/camel-api/src/main/java/org/apache/camel/NoSuchBeanException.java index f02c11069283..e64e2c4acf1a 100644 --- a/core/camel-api/src/main/java/org/apache/camel/NoSuchBeanException.java +++ b/core/camel-api/src/main/java/org/apache/camel/NoSuchBeanException.java @@ -78,6 +78,18 @@ public class NoSuchBeanException extends RuntimeCamelException { this.name = name; } + /** + * @param name the bean name that could not be found + * @param type the required bean type + * @param hint what to do about it, appended to the message (such as the built-in bean that was likely meant) + * @since 4.23 + */ + public NoSuchBeanException(String name, String type, String hint) { + super("No bean could be found in the registry for: " + Objects.requireNonNull(name, "name") + " of type: " + + Objects.requireNonNull(type, "type") + (hint != null ? hint : "")); + this.name = name; + } + /** * @param name the bean name that could not be found * @param message the detail message diff --git a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/AbstractReifier.java b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/AbstractReifier.java index f7e32a4740ea..ae4a62f2939b 100644 --- a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/AbstractReifier.java +++ b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/AbstractReifier.java @@ -38,6 +38,7 @@ import org.apache.camel.reifier.language.ExpressionReifier; import org.apache.camel.spi.BeanRepository; import org.apache.camel.support.CamelContextHelper; import org.apache.camel.support.EndpointHelper; +import org.apache.camel.support.PojoBeanHelper; import org.apache.camel.util.ObjectHelper; public abstract class AbstractReifier implements BeanRepository { @@ -178,7 +179,8 @@ public abstract class AbstractReifier implements BeanRepository { Object obj = lookupByNameAndType(name, type); if (obj == null) { - throw new NoSuchBeanException(name, type.getName()); + // a #class: whose class was not found: say which built-in bean was likely meant + throw new NoSuchBeanException(name, type.getName(), PojoBeanHelper.classNotFoundHint(camelContext, name, type)); } return type.cast(obj); } @@ -201,6 +203,24 @@ public abstract class AbstractReifier implements BeanRepository { return answer; } + /** + * Looks up a bean by name as {@link #lookupByName(String)} does; the bean may be of any type (such as a POJO that + * is adapted afterwards). A <tt>#class:</tt> whose class does not exist fails here, with the built-in bean of the + * expected type that was likely meant, instead of answering <tt>null</tt>. + * + * @param name the bean name or <tt>#class:</tt> reference + * @param expectedType the type the bean is expected to be, for the error message only + */ + public Object lookupByName(String name, Class<?> expectedType) { + Object answer = lookupByName(name); + if (answer == null && name != null && parseString(name).startsWith("#class:")) { + name = parseString(name); + throw new NoSuchBeanException( + name, expectedType.getName(), PojoBeanHelper.classNotFoundHint(camelContext, name, expectedType)); + } + return answer; + } + public <T> T lookupByNameAndType(String name, Class<T> type) { if (name == null) { return null; diff --git a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/MulticastReifier.java b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/MulticastReifier.java index a50c7c1791eb..ef1df16ddcec 100644 --- a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/MulticastReifier.java +++ b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/MulticastReifier.java @@ -90,7 +90,7 @@ public class MulticastReifier extends ProcessorReifier<MulticastDefinition> { AggregationStrategy strategy = definition.getAggregationStrategyBean(); String ref = parseString(definition.getAggregationStrategy()); if (strategy == null && ref != null) { - Object aggStrategy = lookupByName(ref); + Object aggStrategy = lookupByName(ref, AggregationStrategy.class); if (aggStrategy == null) { aggStrategy = lookupByNameAndType(ref, AggregationStrategy.class); } diff --git a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/ProcessorReifier.java b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/ProcessorReifier.java index 2d964fa98a2d..30e93a906380 100644 --- a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/ProcessorReifier.java +++ b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/ProcessorReifier.java @@ -934,7 +934,7 @@ public abstract class ProcessorReifier<T extends ProcessorDefinition<?>> extends public AggregationStrategy getConfiguredAggregationStrategy(AggregationStrategyAwareDefinition<?> definition) { AggregationStrategy strategy = definition.getAggregationStrategyBean(); if (strategy == null && definition.getAggregationStrategyRef() != null) { - Object aggStrategy = lookupByName(definition.getAggregationStrategyRef()); + Object aggStrategy = lookupByName(definition.getAggregationStrategyRef(), AggregationStrategy.class); if (aggStrategy == null) { aggStrategy = lookupByNameAndType(definition.getAggregationStrategyRef(), AggregationStrategy.class); } diff --git a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/RecipientListReifier.java b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/RecipientListReifier.java index 08febd7be9cd..a8b015be97f1 100644 --- a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/RecipientListReifier.java +++ b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/RecipientListReifier.java @@ -117,7 +117,7 @@ public class RecipientListReifier extends ProcessorReifier<RecipientListDefiniti AggregationStrategy strategy = definition.getAggregationStrategyBean(); String ref = parseString(definition.getAggregationStrategy()); if (strategy == null && ref != null) { - Object aggStrategy = lookupByName(ref); + Object aggStrategy = lookupByName(ref, AggregationStrategy.class); if (aggStrategy == null) { aggStrategy = lookupByNameAndType(ref, AggregationStrategy.class); } diff --git a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/SplitReifier.java b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/SplitReifier.java index 7c6e2c3db63c..a9abe990aebd 100644 --- a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/SplitReifier.java +++ b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/SplitReifier.java @@ -150,7 +150,7 @@ public class SplitReifier extends ExpressionReifier<SplitDefinition> { private AggregationStrategy createAggregationStrategy() { AggregationStrategy strategy = definition.getAggregationStrategyBean(); if (strategy == null && definition.getAggregationStrategy() != null) { - Object aggStrategy = lookupByName(definition.getAggregationStrategy()); + Object aggStrategy = lookupByName(definition.getAggregationStrategy(), AggregationStrategy.class); if (aggStrategy == null) { aggStrategy = lookupByNameAndType(definition.getAggregationStrategy(), AggregationStrategy.class); } diff --git a/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregationStrategyClassNotFoundHintTest.java b/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregationStrategyClassNotFoundHintTest.java new file mode 100644 index 000000000000..721b98c7282d --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregationStrategyClassNotFoundHintTest.java @@ -0,0 +1,113 @@ +/* + * 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.processor.aggregator; + +import org.apache.camel.ContextTestSupport; +import org.apache.camel.NoSuchBeanException; +import org.apache.camel.builder.RouteBuilder; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A #class: aggregation strategy whose class does not exist fails with the built-in strategy that was likely meant + * (from the bean metadata on the classpath), or with the built-in strategies to pick from. + */ +public class AggregationStrategyClassNotFoundHintTest extends ContextTestSupport { + + @Override + public boolean isUseRouteBuilder() { + return false; + } + + @Test + public void testWrongPackageSaysDidYouMean() { + String msg = startWithStrategy("#class:com.foo.UseLatestAggregationStrategy"); + + assertTrue(msg.contains("com.foo.UseLatestAggregationStrategy"), msg); + assertTrue(msg.contains("did you mean org.apache.camel.processor.aggregate.UseLatestAggregationStrategy" + + " (org.apache.camel.AggregationStrategy)?"), + msg); + } + + @Test + public void testNoPackageSaysDidYouMean() { + String msg = startWithStrategy("#class:GroupedBodyAggregationStrategy"); + + assertTrue(msg.contains("did you mean org.apache.camel.processor.aggregate.GroupedBodyAggregationStrategy" + + " (org.apache.camel.AggregationStrategy)?"), + msg); + } + + @Test + public void testUnknownClassListsBuiltInStrategies() { + String msg = startWithStrategy("#class:com.foo.MyStrategy"); + + assertFalse(msg.contains("did you mean"), msg); + assertTrue(msg.contains("check the package name; a class from another library needs its dependency added"), msg); + assertTrue(msg.contains("the built-in AggregationStrategy beans are"), msg); + assertTrue(msg.contains("AggregationStrategy (org.apache.camel.processor.aggregate."), msg); + // more than 6 built-in strategies on the classpath, so the rest are counted + assertTrue(msg.contains(" more)"), msg); + } + + @Test + public void testSplitStrategyAlsoGetsTheHint() { + Exception e = assertThrows(Exception.class, () -> { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:start").split(body()).aggregationStrategy("#class:com.foo.StringAggregationStrategy") + .to("mock:result"); + } + }); + context.start(); + }); + String msg = messages(e); + + assertTrue(msg.contains("did you mean org.apache.camel.processor.aggregate.StringAggregationStrategy"), msg); + } + + private String startWithStrategy(String ref) { + Exception e = assertThrows(Exception.class, () -> { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:start").aggregate(header("id")).aggregationStrategy(ref).completionSize(1) + .to("mock:result"); + } + }); + context.start(); + }); + String msg = messages(e); + assertTrue(msg.contains("No bean could be found in the registry for: " + ref), msg); + return msg; + } + + private static String messages(Throwable e) { + StringBuilder sb = new StringBuilder(); + boolean noSuchBean = false; + for (Throwable t = e; t != null; t = t.getCause()) { + noSuchBean |= t instanceof NoSuchBeanException; + sb.append(t.getMessage()).append('\n'); + } + assertTrue(noSuchBean, sb.toString()); + return sb.toString(); + } +} diff --git a/core/camel-core/src/test/java/org/apache/camel/support/PojoBeanHelperTest.java b/core/camel-core/src/test/java/org/apache/camel/support/PojoBeanHelperTest.java new file mode 100644 index 000000000000..e19ea5a00980 --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/support/PojoBeanHelperTest.java @@ -0,0 +1,131 @@ +/* + * 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.support; + +import java.util.List; + +import org.apache.camel.AggregationStrategy; +import org.apache.camel.CamelContext; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.spi.AggregationRepository; +import org.apache.camel.spi.IdempotentRepository; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class PojoBeanHelperTest { + + private final CamelContext context = new DefaultCamelContext(); + + @Test + public void testFindAllReadsEveryJar() { + List<PojoBeanHelper.PojoBean> beans = PojoBeanHelper.findAll(context); + + // camel-core-processor and camel-support both ship beans + assertTrue(beans.stream().anyMatch(b -> b.name().equals("UseLatestAggregationStrategy")), beans.toString()); + assertTrue(beans.stream().anyMatch(b -> b.name().equals("MemoryIdempotentRepository")), beans.toString()); + assertTrue(beans.stream().anyMatch(b -> b.name().equals("MemoryAggregationRepository")), beans.toString()); + // no duplicates when the same jar is visible through more than one class loader + assertEquals(beans.size(), beans.stream().map(PojoBeanHelper.PojoBean::javaType).distinct().count()); + } + + @Test + public void testFindByName() { + PojoBeanHelper.PojoBean bean = PojoBeanHelper.findByName(context, "com.foo.UseLatestAggregationStrategy"); + assertNotNull(bean); + assertEquals("UseLatestAggregationStrategy", bean.name()); + assertEquals("org.apache.camel.processor.aggregate.UseLatestAggregationStrategy", bean.javaType()); + assertEquals("org.apache.camel.AggregationStrategy", bean.interfaceType()); + assertEquals("org.apache.camel", bean.groupId()); + assertEquals("camel-core-processor", bean.artifactId()); + + // simple name, case-insensitive + assertNotNull(PojoBeanHelper.findByName(context, "memoryAggregationRepository")); + assertNull(PojoBeanHelper.findByName(context, "com.foo.MyBean")); + assertNull(PojoBeanHelper.findByName(context, null)); + } + + @Test + public void testBeansOfInterface() { + List<PojoBeanHelper.PojoBean> beans = PojoBeanHelper.beansOfInterface(context, IdempotentRepository.class.getName()); + assertTrue(beans.stream().anyMatch(b -> b.name().equals("MemoryIdempotentRepository")), beans.toString()); + assertTrue(beans.stream().anyMatch(b -> b.name().equals("FileIdempotentRepository")), beans.toString()); + assertTrue(beans.stream().allMatch(b -> b.interfaceType().equals(IdempotentRepository.class.getName()))); + + // by simple name too + assertEquals(beans.size(), PojoBeanHelper.beansOfInterface(context, "IdempotentRepository").size()); + assertTrue(PojoBeanHelper.beansOfInterface(context, "com.foo.Unknown").isEmpty()); + } + + @Test + public void testClassNotFoundHintWrongPackage() { + String hint = PojoBeanHelper.classNotFoundHint(context, "com.foo.UseLatestAggregationStrategy", + AggregationStrategy.class); + assertEquals(" (did you mean org.apache.camel.processor.aggregate.UseLatestAggregationStrategy" + + " (org.apache.camel.AggregationStrategy)?)", + hint); + // the expected type does not matter when the name is known + assertEquals(hint, PojoBeanHelper.classNotFoundHint(context, "com.foo.UseLatestAggregationStrategy", null)); + assertEquals(hint, PojoBeanHelper.classNotFoundHint(context, "UseLatestAggregationStrategy", Object.class)); + // a #class: reference, with or without constructor parameters or a factory method + assertEquals(hint, PojoBeanHelper.classNotFoundHint(context, "#class:com.foo.UseLatestAggregationStrategy", null)); + assertEquals(hint, + PojoBeanHelper.classNotFoundHint(context, "#class:com.foo.UseLatestAggregationStrategy('a', 1)", null)); + assertEquals(hint, + PojoBeanHelper.classNotFoundHint(context, "#class:com.foo.UseLatestAggregationStrategy#create", null)); + // other references are bean names, not classes + assertEquals("", PojoBeanHelper.classNotFoundHint(context, "#bean:UseLatestAggregationStrategy", null)); + assertEquals("", PojoBeanHelper.classNotFoundHint(context, "#UseLatestAggregationStrategy", null)); + } + + @Test + public void testClassNotFoundHintUnknownClass() { + assertEquals(" (check the package name; a class from another library needs its dependency added)", + PojoBeanHelper.classNotFoundHint(context, "com.foo.MyBean", null)); + assertEquals(" (check the package name; a class from another library needs its dependency added)", + PojoBeanHelper.classNotFoundHint(context, "com.foo.MyBean", Object.class)); + assertEquals(" (check the package name; a class from another library needs its dependency added)", + PojoBeanHelper.classNotFoundHint(context, "com.foo.MyBean", Runnable.class)); + assertEquals("", PojoBeanHelper.classNotFoundHint(context, null, AggregationStrategy.class)); + } + + @Test + public void testClassNotFoundHintListsBuiltInBeansOfTheExpectedType() { + String hint = PojoBeanHelper.classNotFoundHint(context, "com.foo.MyRepo", AggregationRepository.class); + assertTrue(hint.startsWith(" (check the package name; a class from another library needs its dependency added;" + + " the built-in AggregationRepository beans are "), + hint); + assertTrue(hint.contains( + "MemoryAggregationRepository (org.apache.camel.processor.aggregate.MemoryAggregationRepository)"), hint); + assertTrue(hint.endsWith(")"), hint); + assertFalse(hint.contains("IdempotentRepository"), hint); + } + + @Test + public void testMissingClassName() { + assertEquals("com.foo.Bar", PojoBeanHelper.missingClassName(new ClassNotFoundException("com.foo.Bar"))); + assertEquals("com.foo.Bar", PojoBeanHelper.missingClassName( + new RuntimeException("wrapped", new ClassNotFoundException("com.foo.Bar")))); + assertEquals("com.foo.Bar", PojoBeanHelper.missingClassName(new NoClassDefFoundError("com/foo/Bar"))); + assertNull(PojoBeanHelper.missingClassName(new IllegalStateException("nope"))); + assertNull(PojoBeanHelper.missingClassName(null)); + } +} diff --git a/core/camel-support/src/main/java/org/apache/camel/support/EndpointHelper.java b/core/camel-support/src/main/java/org/apache/camel/support/EndpointHelper.java index 05a3d91f3e2d..21228c57be3d 100644 --- a/core/camel-support/src/main/java/org/apache/camel/support/EndpointHelper.java +++ b/core/camel-support/src/main/java/org/apache/camel/support/EndpointHelper.java @@ -385,7 +385,9 @@ public final class EndpointHelper { if (mandatory && answer == null) { if (type != null) { - throw new NoSuchBeanException(value, type.getTypeName()); + // a #class: whose class was not found: say which built-in bean was likely meant + throw new NoSuchBeanException( + value, type.getTypeName(), PojoBeanHelper.classNotFoundHint(context, value, type)); } else { throw new NoSuchBeanException(value); } diff --git a/core/camel-support/src/main/java/org/apache/camel/support/PojoBeanHelper.java b/core/camel-support/src/main/java/org/apache/camel/support/PojoBeanHelper.java new file mode 100644 index 000000000000..b37784673684 --- /dev/null +++ b/core/camel-support/src/main/java/org/apache/camel/support/PojoBeanHelper.java @@ -0,0 +1,243 @@ +/* + * 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.support; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +import org.apache.camel.CamelContext; +import org.apache.camel.util.IOHelper; +import org.apache.camel.util.StringHelper; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Looks up the beans that Camel ships out of the box (aggregation strategies, idempotent and aggregation repositories, + * header filter strategies, ...) from the metadata the build tools generate into every JAR that has such beans: + * <tt>META-INF/services/org/apache/camel/bean.properties</tt> lists the bean names, and + * <tt>META-INF/services/org/apache/camel/bean/<Name>.json</tt> has the class, the interface and the artifact. + * <p/> + * The metadata is read when a class cannot be found, so the error can say which built-in bean was likely meant. The + * classpath is scanned on each call; this is an error-path helper and not meant for hot paths. + */ +public final class PojoBeanHelper { + + /** Where the build tools generate the bean names of a JAR (one file per JAR). */ + public static final String BEAN_PROPERTIES = "META-INF/services/org/apache/camel/bean.properties"; + + /** Where the build tools generate the metadata of a bean (one file per bean). */ + public static final String BEAN_JSON_PATH = "META-INF/services/org/apache/camel/bean/"; + + /** How many built-in beans of an interface a hint lists before saying "and N more". */ + private static final int MAX_LISTED = 6; + + /** + * A bean Camel ships out of the box, as described by its generated metadata. + * + * @param name the bean name (the simple class name) + * @param javaType the fully qualified class name + * @param interfaceType the fully qualified name of the interface the bean implements (may be null) + * @param groupId the Maven groupId of the artifact that ships the bean + * @param artifactId the Maven artifactId of the artifact that ships the bean + */ + public record PojoBean(String name, String javaType, String interfaceType, String groupId, String artifactId) { + } + + private PojoBeanHelper() { + } + + /** + * All the built-in beans whose metadata is on the classpath. + */ + public static List<PojoBean> findAll(CamelContext camelContext) { + List<PojoBean> answer = new ArrayList<>(); + for (URL url : findBeanProperties(camelContext)) { + Properties props = new Properties(); + try (InputStream is = url.openStream()) { + props.load(is); + } catch (IOException e) { + continue; + } + String names = props.getProperty("bean", ""); + for (String name : names.split("\\s+")) { + if (!name.isBlank()) { + PojoBean bean = loadBean(camelContext, name.trim(), props); + if (bean != null) { + answer.add(bean); + } + } + } + } + return answer; + } + + /** + * The built-in bean with the given name, matched case-insensitively by the simple class name or by the fully + * qualified class name, or <tt>null</tt> if there is none. + */ + public static PojoBean findByName(CamelContext camelContext, String name) { + if (name == null || name.isBlank()) { + return null; + } + String simple = name.substring(name.lastIndexOf('.') + 1); + for (PojoBean bean : findAll(camelContext)) { + if (bean.name().equalsIgnoreCase(simple) || bean.javaType().equalsIgnoreCase(name)) { + return bean; + } + } + return null; + } + + /** + * The built-in beans of an interface, given by its fully qualified or simple name. + */ + public static List<PojoBean> beansOfInterface(CamelContext camelContext, String interfaceName) { + List<PojoBean> answer = new ArrayList<>(); + if (interfaceName == null || interfaceName.isBlank()) { + return answer; + } + String simple = interfaceName.substring(interfaceName.lastIndexOf('.') + 1); + for (PojoBean bean : findAll(camelContext)) { + String iface = bean.interfaceType(); + if (iface != null && (iface.equals(interfaceName) || iface.endsWith("." + simple))) { + answer.add(bean); + } + } + return answer; + } + + /** + * What to do about a class that was not found, from the built-in bean metadata: the built-in bean with that simple + * name (a wrong package), or the built-in beans of the interface the class was expected to implement. The metadata + * is in the same JAR as the bean, so a built-in bean whose JAR is missing gets the generic hint. + * + * @param camelContext the camel context + * @param className the class that was not found (fully qualified or simple name), or a <tt>#class:</tt> + * reference to it; anything else (a bean name) gives no hint + * @param expectedType the interface the class was expected to implement, or <tt>null</tt> (or Object) if unknown + * @return the hint, in parentheses with a leading space, or an empty string if there is nothing to say + */ + public static String classNotFoundHint(CamelContext camelContext, String className, Class<?> expectedType) { + if (className == null || className.isBlank()) { + return ""; + } + if (className.startsWith("#")) { + if (!className.startsWith("#class:")) { + return ""; + } + // strip the reference prefix and any factory method or constructor parameters + className = className.substring(7); + className = StringHelper.before(className, "(", className); + className = StringHelper.before(className, "#", className); + } + PojoBean bean = findByName(camelContext, className); + if (bean != null && !bean.javaType().equals(className)) { + return " (did you mean " + bean.javaType() + + (bean.interfaceType() != null ? " (" + bean.interfaceType() + ")" : "") + "?)"; + } + String hint = "check the package name; a class from another library needs its dependency added"; + if (expectedType != null && expectedType != Object.class) { + List<PojoBean> beans = beansOfInterface(camelContext, expectedType.getName()); + if (!beans.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < beans.size() && i < MAX_LISTED; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(beans.get(i).name()).append(" (").append(beans.get(i).javaType()).append(")"); + } + if (beans.size() > MAX_LISTED) { + sb.append(" and ").append(beans.size() - MAX_LISTED).append(" more"); + } + hint += "; the built-in " + expectedType.getSimpleName() + " beans are " + sb; + } + } + return " (" + hint + ")"; + } + + /** + * The class named by a ClassNotFoundException or NoClassDefFoundError in the cause chain, in dotted form, or + * <tt>null</tt> if the failure is not a missing class. + */ + public static String missingClassName(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof ClassNotFoundException || t instanceof NoClassDefFoundError) { + // NoClassDefFoundError names the class in internal form (java/lang/Foo) + return t.getMessage() != null ? t.getMessage().trim().replace('/', '.') : null; + } + } + return null; + } + + private static PojoBean loadBean(CamelContext camelContext, String name, Properties props) { + try (InputStream is = camelContext.getClassResolver().loadResourceAsStream(BEAN_JSON_PATH + name + ".json")) { + if (is == null) { + return null; + } + JsonObject root = (JsonObject) Jsoner.deserialize(IOHelper.loadText(is)); + JsonObject bean = root.getMap("bean"); + if (bean == null || bean.getString("javaType") == null) { + return null; + } + return new PojoBean( + bean.getStringOrDefault("name", name), bean.getString("javaType"), bean.getString("interfaceType"), + bean.getStringOrDefault("groupId", props.getProperty("groupId")), + bean.getStringOrDefault("artifactId", props.getProperty("artifactId"))); + } catch (Exception e) { + return null; + } + } + + private static List<URL> findBeanProperties(CamelContext camelContext) { + // dedupe by URI as URL equals/hashCode may resolve host names + Set<URI> seen = new LinkedHashSet<>(); + List<URL> answer = new ArrayList<>(); + addResources(seen, answer, camelContext.getClassResolver().loadAllResourcesAsURL(BEAN_PROPERTIES)); + ClassLoader acl = camelContext.getApplicationContextClassLoader(); + if (acl != null) { + try { + addResources(seen, answer, acl.getResources(BEAN_PROPERTIES)); + } catch (IOException e) { + // ignore + } + } + return answer; + } + + private static void addResources(Set<URI> seen, List<URL> answer, Enumeration<URL> resources) { + while (resources != null && resources.hasMoreElements()) { + URL url = resources.nextElement(); + try { + if (seen.add(url.toURI())) { + answer.add(url); + } + } catch (URISyntaxException e) { + // ignore + } + } + } + +} diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/BeansDeserializer.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/BeansDeserializer.java index cc616d59e384..b754dc3f802d 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/BeansDeserializer.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/BeansDeserializer.java @@ -36,6 +36,7 @@ import org.apache.camel.spi.annotations.YamlIn; import org.apache.camel.spi.annotations.YamlProperty; import org.apache.camel.spi.annotations.YamlType; import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.PojoBeanHelper; import org.apache.camel.util.ObjectHelper; import org.snakeyaml.engine.v2.api.ConstructNode; import org.snakeyaml.engine.v2.nodes.Node; @@ -125,7 +126,7 @@ public class BeansDeserializer extends YamlDeserializerSupport implements Constr } else { String msg = name != null ? "Error creating bean: " + name + " of type: " + type : "Error creating bean: " + type; - throw new RuntimeException(msg + classNotFoundHint(e), e); + throw new RuntimeException(msg + classNotFoundHint(camelContext, e), e); } } } @@ -192,28 +193,22 @@ public class BeansDeserializer extends YamlDeserializerSupport implements Constr /** * The cause of a bean that could not be created is a ClassNotFoundException more often than not (a wrong package, a - * missing dependency); say so, and for a Camel aggregation strategy written in the wrong package name the right - * one. + * missing dependency); say so, and for a built-in Camel bean written with the wrong package name (or with no + * package) the right one, from the bean metadata on the classpath. */ - static String classNotFoundHint(Throwable e) { - for (Throwable t = e; t != null; t = t.getCause()) { - if (t instanceof ClassNotFoundException || t instanceof NoClassDefFoundError) { - // NoClassDefFoundError names the class in internal form (java/lang/Foo) - String cls = t.getMessage() != null ? t.getMessage().trim().replace('/', '.') : ""; - String simple = cls.substring(cls.lastIndexOf('.') + 1); - String hint = ": class " + cls + " was not found"; - if (simple.endsWith("AggregationStrategy") && !cls.startsWith("org.apache.camel.processor.aggregate.")) { - String candidate = "org.apache.camel.processor.aggregate." + simple; - try { - Class.forName(candidate, false, BeansDeserializer.class.getClassLoader()); - return hint + " (did you mean " + candidate + "?)"; - } catch (Throwable ignore) { - // not one of the built-in strategies - } - } - return hint + " (check the package name; a class from another library needs its dependency added)"; - } + static String classNotFoundHint(CamelContext camelContext, Throwable e) { + String cls = PojoBeanHelper.missingClassName(e); + if (cls == null) { + return ""; } - return ""; + String hint = ": class " + cls + " was not found"; + PojoBeanHelper.PojoBean bean = PojoBeanHelper.findByName(camelContext, cls); + if (bean != null && !bean.javaType().equals(cls)) { + return hint + " (did you mean " + bean.javaType() + + (bean.interfaceType() != null ? " (" + bean.interfaceType() + ")" : "") + + "? write: type: " + bean.javaType() + ")"; + } + return hint + PojoBeanHelper.classNotFoundHint(camelContext, cls, null); } + } diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/AggregateTest.groovy b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/AggregateTest.groovy index ff7f670c80bb..23f90ebaeee8 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/AggregateTest.groovy +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/AggregateTest.groovy @@ -121,4 +121,49 @@ class AggregateTest extends YamlTestSupport { then: MockEndpoint.assertIsSatisfied(context) } + + // CAMEL-24709: a #class: whose class does not exist says which built-in strategy was likely meant + def 'aggregate (strategy-ref class not found)'() { + when: + loadRoutes ''' + - from: + uri: "direct:route" + steps: + - aggregate: + aggregationStrategy: "#class:com.foo.UseLatestAggregationStrategy" + completionSize: 2 + correlationExpression: + simple: "${header.StockSymbol}" + steps: + - to: "mock:route" + ''' + + then: + def e = thrown(FailedToCreateRouteException) + def msg = e.message + '\n' + e.cause?.message + msg.contains('No bean could be found in the registry for: #class:com.foo.UseLatestAggregationStrategy of type: org.apache.camel.AggregationStrategy') + msg.contains('did you mean org.apache.camel.processor.aggregate.UseLatestAggregationStrategy (org.apache.camel.AggregationStrategy)?') + } + + def 'aggregate (strategy-ref unknown class lists the built-in strategies)'() { + when: + loadRoutes ''' + - from: + uri: "direct:route" + steps: + - aggregate: + aggregationStrategy: "#class:com.foo.MyStrategy" + completionSize: 2 + correlationExpression: + simple: "${header.StockSymbol}" + steps: + - to: "mock:route" + ''' + + then: + def e = thrown(FailedToCreateRouteException) + def msg = e.message + '\n' + e.cause?.message + msg.contains('the built-in AggregationStrategy beans are') + msg.contains('UseLatestAggregationStrategy (org.apache.camel.processor.aggregate.UseLatestAggregationStrategy)') + } } diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/BeansTest.groovy b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/BeansTest.groovy index 3c3052258259..d5e5f40af62e 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/BeansTest.groovy +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/BeansTest.groovy @@ -309,4 +309,60 @@ class BeansTest extends YamlTestSupport { } } + // CAMEL-24709: a class that was not found names the built-in bean that was likely meant, from the bean metadata on the classpath + def "beans class not found in wrong package says did you mean"() { + when: + loadRoutes """ + - beans: + - name: myAgg + type: com.foo.UseLatestAggregationStrategy + """ + + then: + def e = thrown(Exception) + def msg = messages(e) + msg.contains('Error creating bean: myAgg of type: #class:com.foo.UseLatestAggregationStrategy') + msg.contains('class com.foo.UseLatestAggregationStrategy was not found') + msg.contains('did you mean org.apache.camel.processor.aggregate.UseLatestAggregationStrategy (org.apache.camel.AggregationStrategy)?') + msg.contains('write: type: org.apache.camel.processor.aggregate.UseLatestAggregationStrategy') + } + + def "beans class not found without package says did you mean"() { + when: + loadRoutes """ + - beans: + - name: myRepo + type: MemoryAggregationRepository + """ + + then: + def e = thrown(Exception) + def msg = messages(e) + msg.contains('class MemoryAggregationRepository was not found') + msg.contains('did you mean org.apache.camel.processor.aggregate.MemoryAggregationRepository (org.apache.camel.spi.AggregationRepository)?') + } + + def "beans class not found that is not a built-in bean keeps the generic hint"() { + when: + loadRoutes """ + - beans: + - name: myBean + type: com.foo.MyBean + """ + + then: + def e = thrown(Exception) + def msg = messages(e) + msg.contains('class com.foo.MyBean was not found (check the package name; a class from another library needs its dependency added)') + !msg.contains('did you mean') + } + + private static String messages(Throwable e) { + def sb = new StringBuilder() + for (Throwable t = e; t != null; t = t.cause) { + sb.append(t.message).append('\n') + } + return sb.toString() + } + }
