From 8d23295b5488d7caf9e3afd051dc2696fd6d3fab Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:45:33 -0400 Subject: [PATCH 1/7] CAMEL-24704: camel-yaml-dsl - the schema accepts beans written as a map keyed by name Task 1 of CAMEL-24704: the lenient camelYamlDsl.json schema now accepts beans: written as a map from the bean name to the rest of the bean definition (beans: {myBean: {type: ...}}), as an alternative to the canonical list form (- name: myBean / type: ...). This applies to the top-level beans, and to beans inside routeTemplate and templatedRoute. The canonical schema and the runtime writer are unaffected: the list stays the only canonical form. - Add YamlProperty.mapKey(): when set on an array: property, the mojo emits a oneOf accepting either the array or a map keyed by that property, referencing a generated "By" definition (the item's own definition without the key property and without it in required). - Mark the three beans YamlProperty declarations (BeansDeserializer, RouteTemplateDefinitionDeserializer, TemplatedRouteDefinitionDeserializer) with mapKey = "name". - Regenerate camelYamlDsl.json; camelYamlDsl-canonical.json and camelYamlDsl-model.json are unchanged, confirming the canonical form is untouched. - Add validator tests: beans as a map passes the classic (lenient) validator but not the canonical one, and a name: inside a map-form bean is rejected (the key is the name). Extend the existing map/list hint test with a canonical-mode assertion and a lenient scalar assertion. _Claude Code on behalf of Adriano Machado (@ammachado)_ _This was generated by an AI agent and may contain inaccuracies. Please verify before relying on it._ Co-authored-by: Claude Opus 5.5 --- .../camel/spi/annotations/YamlProperty.java | 8 ++ .../yaml/deserializers/BeansDeserializer.java | 3 +- .../RouteTemplateDefinitionDeserializer.java | 3 +- .../TemplatedRouteDefinitionDeserializer.java | 3 +- .../dsl/yaml/GenerateYamlSchemaMojo.java | 58 +++++++++ .../validator/YamlCanonicalValidatorTest.java | 36 ++++++ .../YamlValidatorPropertyHintTest.java | 10 +- .../resources/schema/camelYamlDsl.json | 116 ++++++++++++++++-- .../camel/spi/annotations/YamlProperty.java | 8 ++ 9 files changed, 228 insertions(+), 17 deletions(-) diff --git a/core/camel-api/src/generated/java/org/apache/camel/spi/annotations/YamlProperty.java b/core/camel-api/src/generated/java/org/apache/camel/spi/annotations/YamlProperty.java index 764b5b4031eba..40a36465587a7 100644 --- a/core/camel-api/src/generated/java/org/apache/camel/spi/annotations/YamlProperty.java +++ b/core/camel-api/src/generated/java/org/apache/camel/spi/annotations/YamlProperty.java @@ -45,4 +45,12 @@ String oneOf() default ""; boolean wrapItem() default false; + + /** + * The name of the property that identifies an item of this list (such as name). When set, the list may also be + * written as a map from that property to the rest of the item, which the non-canonical schema accepts. + * + * @since 4.23 + */ + String mapKey() default ""; } 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 92b4ef071ea1b..e904347fb8e5d 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 @@ -48,7 +48,8 @@ order = YamlDeserializerResolver.ORDER_DEFAULT, properties = { @YamlProperty(name = "__extends", - type = "array:org.apache.camel.model.BeanFactoryDefinition") + type = "array:org.apache.camel.model.BeanFactoryDefinition", + mapKey = "name") }) public class BeansDeserializer extends YamlDeserializerSupport implements ConstructNode { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/RouteTemplateDefinitionDeserializer.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/RouteTemplateDefinitionDeserializer.java index 2322b47349f3e..413d4eac94681 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/RouteTemplateDefinitionDeserializer.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/RouteTemplateDefinitionDeserializer.java @@ -49,7 +49,8 @@ @YamlProperty(name = "parameters", type = "array:org.apache.camel.model.RouteTemplateParameterDefinition"), @YamlProperty(name = "beans", - type = "array:org.apache.camel.model.BeanFactoryDefinition") + type = "array:org.apache.camel.model.BeanFactoryDefinition", + mapKey = "name") }) public class RouteTemplateDefinitionDeserializer extends YamlDeserializerBase { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/TemplatedRouteDefinitionDeserializer.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/TemplatedRouteDefinitionDeserializer.java index 93f4f62a4cbcf..14d24d520b269 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/TemplatedRouteDefinitionDeserializer.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/TemplatedRouteDefinitionDeserializer.java @@ -47,7 +47,8 @@ @YamlProperty(name = "parameters", type = "array:org.apache.camel.model.TemplatedRouteParameterDefinition"), @YamlProperty(name = "beans", - type = "array:org.apache.camel.model.BeanFactoryDefinition") + type = "array:org.apache.camel.model.BeanFactoryDefinition", + mapKey = "name") }) public class TemplatedRouteDefinitionDeserializer extends YamlDeserializerBase { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java index 117822f3c546a..4bc940298061e 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java @@ -83,6 +83,9 @@ public class GenerateYamlSchemaMojo extends GenerateYamlSupportMojo { private ObjectNode definitions; private ObjectNode step; + /** The item types of lists that may be written as a map, to the property the map is keyed by. */ + private final Map keyedDefinitions = new TreeMap<>(); + @Override protected void generate() throws MojoFailureException { final ObjectMapper mapper = JsonMapper.builder() @@ -172,6 +175,8 @@ protected void generate() throws MojoFailureException { postProcessInheritance(inheritedDefinitions, inlineDefinitions); } + generateKeyedDefinitions(); + try { ToolingSupport.mkparents(outputFile); @@ -251,6 +256,9 @@ private void generate(String type, ClassInfo info, Set inheritedDefiniti final boolean propertyWrapItem = annotationValue(property, "wrapItem") .map(AnnotationValue::asBoolean) .orElse(false); + final String propertyMapKey = annotationValue(property, "mapKey") + .map(AnnotationValue::asString) + .orElse(""); boolean isInOneOf = !canonical && !StringUtils.isEmpty(propertyOneOf); if (isInOneOf) { @@ -290,6 +298,12 @@ private void generate(String type, ClassInfo info, Set inheritedDefiniti .withObject("/items") .put("$ref", "#/items/definitions/" + objectRef); + if (!canonical && !propertyMapKey.isEmpty()) { + // the root beans definition is the array itself: it gets no additionalProperties of its own + objectDefinition.remove("additionalProperties"); + allowMapForm(objectDefinition, objectRef, propertyMapKey); + } + continue; } if (propertyName.startsWith("__")) { @@ -339,6 +353,11 @@ private void generate(String type, ClassInfo info, Set inheritedDefiniti propertyWrapItem, additionalProperties); + if (!canonical && !propertyMapKey.isEmpty() && propertyType.startsWith("array:")) { + allowMapForm(finalObjectDefinition.withObject("/properties/" + propertyName), + StringHelper.after(propertyType, ":"), propertyMapKey); + } + // A property that belongs to a oneOf group (e.g. the data formats of marshal/unmarshal, or the // languages of an expression) is only required as part of choosing one of the alternatives, not // unconditionally. In canonical mode the oneOf grouping itself is dropped (isInOneOf is always @@ -527,6 +546,45 @@ private void setProperty( } } + /** + * A list whose items are identified by a property (a bean by its name) may also be written as a map from that + * property to the rest of the item: beans: {myBean: {type: ...}} (CAMEL-24704). + */ + private void allowMapForm(ObjectNode schema, String itemType, String mapKey) { + schema.remove("type"); + JsonNode items = schema.remove("items"); + ArrayNode oneOf = schema.withArray("oneOf"); + oneOf.addObject().put("type", "array").set("items", items); + oneOf.addObject().put("type", "object") + .withObject("/additionalProperties") + .put("$ref", "#/items/definitions/" + keyedName(itemType, mapKey)); + keyedDefinitions.put(itemType, mapKey); + } + + private static String keyedName(String itemType, String mapKey) { + return itemType + "By" + StringHelper.capitalize(mapKey); + } + + /** The item definition of the map form: the item's own definition without the key property. */ + private void generateKeyedDefinitions() { + keyedDefinitions.forEach((itemType, mapKey) -> { + ObjectNode keyed = definitions.withObject("/" + itemType).deepCopy(); + keyed.withObject("/properties").remove(mapKey); + if (keyed.has("required")) { + ArrayNode required = keyed.withArray("required"); + for (int i = required.size() - 1; i >= 0; i--) { + if (mapKey.equals(required.get(i).asText())) { + required.remove(i); + } + } + if (required.isEmpty()) { + keyed.remove("required"); + } + } + definitions.set(keyedName(itemType, mapKey), keyed); + }); + } + private void collectYamlProperties(List annotations, ClassInfo info) { if (info == null) { return; diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlCanonicalValidatorTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlCanonicalValidatorTest.java index 43cc7bbdb3767..4a5da250d848d 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlCanonicalValidatorTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlCanonicalValidatorTest.java @@ -250,4 +250,40 @@ public void testCompactNotationIsNotReportedByTheClassicValidator() throws Excep """; assertThat(classicValidator.validate(route)).isEmpty(); } + + private static final String BEANS_AS_MAP = """ + - beans: + myProcessor: + type: "#class:com.example.MyProcessor" + properties: + greeting: hello + - routeTemplate: + id: myTemplate + beans: + templateBean: + type: "#class:com.example.MyBean" + from: + uri: direct:a + steps: + - to: + uri: log:a + """; + + /** CAMEL-24704: beans may be a map keyed by the bean name; the canonical form stays the list. */ + @Test + public void testBeansAsMapPassClassicButNotCanonicalValidation() throws Exception { + assertThat(classicValidator.validate(BEANS_AS_MAP)).isEmpty(); + assertThat(canonicalValidator.validate(BEANS_AS_MAP)).isNotEmpty(); + } + + /** In the map form the key is the name, so a name: inside the bean is not accepted. */ + @Test + public void testBeansAsMapRejectNameProperty() throws Exception { + assertThat(classicValidator.validate(""" + - beans: + myProcessor: + name: other + type: "#class:com.example.MyProcessor" + """)).isNotEmpty(); + } } diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java index e41a7b1d7e7a4..5740a7a54008d 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java @@ -372,7 +372,9 @@ public void testMapWhereAListIsExpectedSaysHowToWriteIt() throws Exception { """); assertThat(errors).anyMatch(e -> e.getMessage().contains("array expected") && e.getMessage().contains("- route:")); - errors = validator.validate(""" + YamlValidator canonical = new YamlValidator(true); + canonical.init(); + errors = canonical.validate(""" - beans: myBean: type: "#class:com.example.MyBean" @@ -384,6 +386,12 @@ public void testMapWhereAListIsExpectedSaysHowToWriteIt() throws Exception { """); assertThat(errors).anyMatch(e -> e.getMessage().contains("beans is a list: - name: myBean")); + // lenient: a map is fine (CAMEL-24704), a scalar still says how to write it + errors = validator.validate(""" + - beans: myBean + """); + assertThat(errors).anyMatch(e -> e.getMessage().contains("beans is a list: - name: myBean")); + errors = validator.validate(""" - from: uri: timer:tick diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json index dbf079b5f2c45..64a60d2e52b23 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json @@ -211,11 +211,17 @@ } }, "org.apache.camel.dsl.yaml.deserializers.BeansDeserializer" : { - "type" : "array", - "additionalProperties" : false, - "items" : { - "$ref" : "#/items/definitions/org.apache.camel.model.BeanFactoryDefinition" - } + "oneOf" : [ { + "type" : "array", + "items" : { + "$ref" : "#/items/definitions/org.apache.camel.model.BeanFactoryDefinition" + } + }, { + "type" : "object", + "additionalProperties" : { + "$ref" : "#/items/definitions/org.apache.camel.model.BeanFactoryDefinitionByName" + } + } ] }, "org.apache.camel.dsl.yaml.deserializers.DataFormatsDefinitionDeserializer" : { "type" : "array", @@ -5434,10 +5440,17 @@ "additionalProperties" : false, "properties" : { "beans" : { - "type" : "array", - "items" : { - "$ref" : "#/items/definitions/org.apache.camel.model.BeanFactoryDefinition" - } + "oneOf" : [ { + "type" : "array", + "items" : { + "$ref" : "#/items/definitions/org.apache.camel.model.BeanFactoryDefinition" + } + }, { + "type" : "object", + "additionalProperties" : { + "$ref" : "#/items/definitions/org.apache.camel.model.BeanFactoryDefinitionByName" + } + } ] }, "description" : { "type" : "string" @@ -6593,10 +6606,17 @@ "additionalProperties" : false, "properties" : { "beans" : { - "type" : "array", - "items" : { - "$ref" : "#/items/definitions/org.apache.camel.model.BeanFactoryDefinition" - } + "oneOf" : [ { + "type" : "array", + "items" : { + "$ref" : "#/items/definitions/org.apache.camel.model.BeanFactoryDefinition" + } + }, { + "type" : "object", + "additionalProperties" : { + "$ref" : "#/items/definitions/org.apache.camel.model.BeanFactoryDefinitionByName" + } + } ] }, "group" : { "type" : "string" @@ -16640,6 +16660,76 @@ "xquery" : { }, "xtokenize" : { } } + }, + "org.apache.camel.model.BeanFactoryDefinitionByName" : { + "title" : "Bean Factory", + "description" : "Defines a custom bean that can be used in Camel routes and other beans. The bean is created by a factory class or method.", + "type" : "object", + "additionalProperties" : false, + "properties" : { + "builderClass" : { + "type" : "string", + "title" : "Builder Class", + "description" : "Fully qualified class name of builder class to use for creating and configuring the bean. The builder will use the properties values to configure the bean. Not needed for a type that has no public no-arg constructor but a public static builder() or newBuilder() method (such as Lombok, Immutables, LangChain4j or AWS SDK classes): Camel then infers the builder, sets the properties on it, and creates the bean with its build method." + }, + "builderMethod" : { + "type" : "string", + "title" : "Builder Method", + "description" : "Name of method when using builder class (or an inferred builder). This method is invoked after configuring to create the actual bean. This method is often named build (used by default).", + "default" : "build" + }, + "constructors" : { + "type" : "object", + "title" : "Constructors", + "description" : "Optional constructor arguments for creating the bean. Arguments correspond to specific index of the constructor argument list, starting from zero." + }, + "destroyMethod" : { + "type" : "string", + "title" : "Destroy Method", + "description" : "The name of the custom destroy method to invoke on bean shutdown, such as when Camel is shutting down. The method must have no arguments, but may throw any exception." + }, + "factoryBean" : { + "type" : "string", + "title" : "Factory Bean", + "description" : "Name of factory bean (bean id) to use for creating the bean." + }, + "factoryMethod" : { + "type" : "string", + "title" : "Factory Method", + "description" : "Name of method to invoke when creating the bean via a factory bean." + }, + "initMethod" : { + "type" : "string", + "title" : "Init Method", + "description" : "The name of the custom initialization method to invoke after setting bean properties. The method must have no arguments, but may throw any exception." + }, + "properties" : { + "type" : "object", + "title" : "Properties", + "description" : "Optional properties to set on the created bean." + }, + "script" : { + "type" : "string", + "title" : "Script", + "description" : "The script to execute that creates the bean when using scripting languages." + }, + "scriptLanguage" : { + "type" : "string", + "title" : "Script Language", + "description" : "The script language to use when using inlined script for creating the bean, such as groovy, java, javascript etc." + }, + "scriptPropertyPlaceholders" : { + "type" : "boolean", + "title" : "Script Property Placeholders", + "description" : "Whether the script should support using Camel property placeholder syntax {{ }}.", + "default" : true + }, + "type" : { + "type" : "string", + "title" : "Type", + "description" : "The class name (fully qualified) of the bean. Required unless the bean is created by a script (scriptLanguage) or a builder (builderClass), where the type is optional and is then the type the bean is registered as." + } + } } }, "properties" : { diff --git a/tooling/spi-annotations/src/main/java/org/apache/camel/spi/annotations/YamlProperty.java b/tooling/spi-annotations/src/main/java/org/apache/camel/spi/annotations/YamlProperty.java index 764b5b4031eba..40a36465587a7 100644 --- a/tooling/spi-annotations/src/main/java/org/apache/camel/spi/annotations/YamlProperty.java +++ b/tooling/spi-annotations/src/main/java/org/apache/camel/spi/annotations/YamlProperty.java @@ -45,4 +45,12 @@ String oneOf() default ""; boolean wrapItem() default false; + + /** + * The name of the property that identifies an item of this list (such as name). When set, the list may also be + * written as a map from that property to the rest of the item, which the non-canonical schema accepts. + * + * @since 4.23 + */ + String mapKey() default ""; } From 46c81b1a9ab60c32da25532fa3d06570227c6de5 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:58:36 -0400 Subject: [PATCH 2/7] CAMEL-24704: camel-yaml-dsl - beans written as a map keyed by name are loaded BeansDeserializer.asBeanDefinitions(Node) now accepts the beans: node as either the canonical list (each item carrying its own name:) or a map keyed by the bean name (CAMEL-24704). For the map form, each entry is rewritten internally as a "- name: " list item followed by its properties before delegating to the existing BeanFactoryDefinition deserialization, so resource assignment, notNull/script checks, the #class: prefix and the pre-parse/parse dedup cache are unchanged for both forms. A bean entry under name: with its own name: property, a non-map value (missing properties indented under the name), or a name declared twice in the map now fail with a message that names the offending bean and says what to write. RouteTemplateDefinitionDeserializer and TemplatedRouteDefinitionDeserializer now delegate to the same helper for their beans: property so routeTemplate and templatedRoute accept the map form too. _Claude Code on behalf of Adriano Machado (@ammachado)_ _This was generated by an AI agent and may contain inaccuracies. Please verify before relying on it._ Co-authored-by: Claude Opus 5.5 --- .../yaml/deserializers/BeansDeserializer.java | 64 ++++++++++++++++-- .../RouteTemplateDefinitionDeserializer.java | 2 +- .../TemplatedRouteDefinitionDeserializer.java | 2 +- .../org/apache/camel/dsl/yaml/BeansTest.java | 64 ++++++++++++++++++ .../camel/dsl/yaml/RouteTemplateTest.java | 41 ++++++++++++ .../camel/dsl/yaml/TemplatedRouteTest.java | 66 +++++++++++++++++++ 6 files changed, 231 insertions(+), 8 deletions(-) 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 e904347fb8e5d..3693b356504bd 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 @@ -27,6 +27,7 @@ import org.apache.camel.dsl.yaml.common.YamlDeserializationContext; import org.apache.camel.dsl.yaml.common.YamlDeserializerResolver; import org.apache.camel.dsl.yaml.common.YamlDeserializerSupport; +import org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException; import org.apache.camel.model.BeanFactoryDefinition; import org.apache.camel.model.BeanModelHelper; import org.apache.camel.model.Model; @@ -39,8 +40,13 @@ import org.apache.camel.support.PojoBeanHelper; import org.apache.camel.util.ObjectHelper; import org.snakeyaml.engine.v2.api.ConstructNode; +import org.snakeyaml.engine.v2.common.ScalarStyle; +import org.snakeyaml.engine.v2.nodes.MappingNode; import org.snakeyaml.engine.v2.nodes.Node; -import org.snakeyaml.engine.v2.nodes.SequenceNode; +import org.snakeyaml.engine.v2.nodes.NodeTuple; +import org.snakeyaml.engine.v2.nodes.NodeType; +import org.snakeyaml.engine.v2.nodes.ScalarNode; +import org.snakeyaml.engine.v2.nodes.Tag; @YamlIn @YamlType( @@ -58,13 +64,9 @@ public class BeansDeserializer extends YamlDeserializerSupport implements Constr @Override public Object construct(Node node) { final BeansCustomizer answer = new BeansCustomizer(); - final SequenceNode sn = asSequenceNode(node); final YamlDeserializationContext dc = getDeserializationContext(node); - for (Node item : sn.getValue()) { - setDeserializationContext(item, dc); - - BeanFactoryDefinition bean = asType(item, BeanFactoryDefinition.class); + for (BeanFactoryDefinition bean : asBeanDefinitions(node)) { if (dc != null) { bean.setResource(dc.getResource()); } @@ -97,6 +99,56 @@ public Object construct(Node node) { return answer; } + /** + * The beans of a beans: node: a list of beans each with its name, or a map from the bean name to the bean + * (CAMEL-24704), read as the list it stands for. + */ + public static List> asBeanDefinitions(Node node) { + List> answer = new ArrayList<>(); + YamlDeserializationContext dc = getDeserializationContext(node); + if (node.getNodeType() != NodeType.MAPPING) { + for (Node item : asSequenceNode(node).getValue()) { + setDeserializationContext(item, dc); + answer.add(asType(item, BeanFactoryDefinition.class)); + } + return answer; + } + Set names = new HashSet<>(); + for (NodeTuple entry : asMappingNode(node).getValue()) { + String name = asText(entry.getKeyNode()); + Node value = entry.getValueNode(); + if (!names.add(name)) { + throw new YamlDeserializationException(entry.getKeyNode(), "the bean " + name + " is declared twice"); + } + if (value.getNodeType() != NodeType.MAPPING) { + throw new YamlDeserializationException( + value, + "a bean written as a map is " + name + + ": followed by its properties (type: ...) indented under the name"); + } + MappingNode bean = (MappingNode) value; + for (NodeTuple property : bean.getValue()) { + if ("name".equals(asText(property.getKeyNode()))) { + throw new YamlDeserializationException( + property.getKeyNode(), + "the key " + name + " is the bean name: remove name: from the bean"); + } + } + // the key is the name: the bean is read as the list item "- name: " followed by its properties + Node key = entry.getKeyNode(); + List tuples = new ArrayList<>(); + tuples.add(new NodeTuple( + new ScalarNode(Tag.STR, true, "name", ScalarStyle.PLAIN, key.getStartMark(), key.getEndMark()), + new ScalarNode(Tag.STR, true, name, ScalarStyle.PLAIN, key.getStartMark(), key.getEndMark()))); + tuples.addAll(bean.getValue()); + MappingNode named = new MappingNode( + bean.getTag(), true, tuples, bean.getFlowStyle(), bean.getStartMark(), bean.getEndMark()); + setDeserializationContext(named, dc); + answer.add(asType(named, BeanFactoryDefinition.class)); + } + return answer; + } + public void clearCache() { beanCache.clear(); } diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/RouteTemplateDefinitionDeserializer.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/RouteTemplateDefinitionDeserializer.java index 413d4eac94681..c8d4ab505e6ac 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/RouteTemplateDefinitionDeserializer.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/RouteTemplateDefinitionDeserializer.java @@ -102,7 +102,7 @@ protected boolean setProperty( } case "beans": { List> items - = (List) asFlatList(node, BeanFactoryDefinition.class); + = (List) BeansDeserializer.asBeanDefinitions(node); target.setTemplateBeans(items); break; } diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/TemplatedRouteDefinitionDeserializer.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/TemplatedRouteDefinitionDeserializer.java index 14d24d520b269..76faeaa5b59ac 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/TemplatedRouteDefinitionDeserializer.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/TemplatedRouteDefinitionDeserializer.java @@ -90,7 +90,7 @@ protected boolean setProperty( } case "beans": { List> items - = (List) asFlatList(node, BeanFactoryDefinition.class); + = (List) BeansDeserializer.asBeanDefinitions(node); target.setBeans(items); break; } diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/BeansTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/BeansTest.java index 207f756a1b80f..c69a6b8658232 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/BeansTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/BeansTest.java @@ -26,6 +26,8 @@ import org.apache.camel.dsl.yaml.support.model.MyDestroyBean; import org.apache.camel.dsl.yaml.support.model.MyFacBean; import org.apache.camel.dsl.yaml.support.model.MyFacHelper; +import org.apache.camel.model.BeanFactoryDefinition; +import org.apache.camel.model.Model; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -406,6 +408,68 @@ void beansClassNotFoundThatIsNotABuiltInBeanKeepsTheGenericHint() { assertThat(msg.contains("did you mean")).isFalse(); } + /** CAMEL-24704: beans written as a map keyed by the bean name. */ + @Test + void beansAsMap() throws Exception { + loadRoutes(""" + - beans: + myNested: + type: %s + properties: + field1: 'f1' + "my.dotted": + type: %s + """.formatted(MyBean.class.getName(), MyBean.class.getName())); + + assertThat(context.getRegistry().lookupByName("myNested")).isInstanceOf(MyBean.class); + assertThat(((MyBean) context.getRegistry().lookupByName("myNested")).getField1()).isEqualTo("f1"); + assertThat(context.getRegistry().lookupByName("my.dotted")).isInstanceOf(MyBean.class); + // pre-parse and parse must not declare the bean twice + assertThat(context.getCamelContextExtension().getContextPlugin(Model.class).getCustomBeans()) + .extracting(BeanFactoryDefinition::getName) + .containsExactly("myNested", "my.dotted"); + } + + @Test + void beansAsEmptyMap() throws Exception { + loadRoutes(""" + - beans: {} + """); + assertThat(context.getCamelContextExtension().getContextPlugin(Model.class).getCustomBeans()).isEmpty(); + } + + @Test + void beansAsMapWithNameFails() { + Exception e = assertThrows(Exception.class, () -> loadRoutesNoValidate(""" + - beans: + myBean: + name: other + type: %s + """.formatted(MyBean.class.getName()))); + assertThat(messages(e)).contains("myBean").contains("remove name:"); + } + + @Test + void beansAsMapWithoutPropertiesFails() { + Exception e = assertThrows(Exception.class, () -> loadRoutesNoValidate(""" + - beans: + myBean: %s + """.formatted(MyBean.class.getName()))); + assertThat(messages(e)).contains("myBean").contains("indented under the name"); + } + + @Test + void beansAsMapWithDuplicateNameFails() { + Exception e = assertThrows(Exception.class, () -> loadRoutesNoValidate(""" + - beans: + myBean: + type: %s + myBean: + type: %s + """.formatted(MyBean.class.getName(), MyBean.class.getName()))); + assertThat(messages(e)).contains("myBean").contains("twice"); + } + private static String messages(Throwable e) { StringBuilder sb = new StringBuilder(); for (Throwable t = e; t != null; t = t.getCause()) { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteTemplateTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteTemplateTest.java index eaca812988cad..61094401aaabd 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteTemplateTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteTemplateTest.java @@ -201,6 +201,47 @@ void createTemplateWithBeanAndProperties() throws Exception { MockEndpoint.assertIsSatisfied(context); } + /** CAMEL-24704: beans written as a map keyed by the bean name. */ + @Test + void createTemplateWithBeanAndPropertiesBeansAsMap() throws Exception { + loadRoutes(""" + - routeTemplate: + id: "myTemplate" + beans: + myProcessor: + type: "#class:%s" + properties: + payload: "test-payload" + from: + uri: "direct:{{directName}}" + steps: + - process: + ref: "{{myProcessor}}" + - from: + uri: "direct:start" + steps: + - to: "direct:myId" + - to: "mock:result" + """.formatted(MySetBody.class.getName())); + + withMock("mock:result", mock -> { + mock.expectedMessageCount(1); + mock.expectedBodiesReceived("test-payload"); + }); + + context.addRouteFromTemplate("myId", "myTemplate", Map.of("directName", "myId")); + context.start(); + + withTemplate(t -> t.to("direct:start").withBody("hello").send()); + + assertThat(context.getRouteTemplateDefinitions().size()).isEqualTo(1); + RouteTemplateDefinition template = context.getRouteTemplateDefinitions().get(0); + assertThat(template.getId()).isEqualTo("myTemplate"); + assertThat(template.getTemplateBeans().size()).isEqualTo(1); + + MockEndpoint.assertIsSatisfied(context); + } + @Test void createTemplateWithBuilderBeanWithoutType() throws Exception { loadRoutes(""" diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/TemplatedRouteTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/TemplatedRouteTest.java index 1f448e4d3bc5e..e1f98521e785b 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/TemplatedRouteTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/TemplatedRouteTest.java @@ -91,6 +91,72 @@ void createTemplatedRouteWithGroup() throws Exception { MockEndpoint.assertIsSatisfied(context); } + /** CAMEL-24704: beans written as a map keyed by the bean name. */ + @Test + void createTemplatedRouteWithGroupBeansAsMap() throws Exception { + loadRoutes(""" + - routeTemplate: + id: "myTemplate" + from: + uri: "direct:{{directName}}" + steps: + - process: + ref: "{{myProcessor}}" + - to: "mock:result" + - templatedRoute: + routeId: "myRoute" + routeTemplateRef: "myTemplate" + group: "myGroup" + parameters: + - name: "directName" + value: "foo" + beans: + myProcessor: + type: "%s" + scriptLanguage: "groovy" + script: | + new %s() + - templatedRoute: + routeId: "myRoute2" + routeTemplateRef: "myTemplate" + group: "myGroup" + parameters: + - name: "directName" + value: "foo2" + beans: + myProcessor: + type: "org.apache.camel.Processor" + scriptLanguage: "groovy" + script: "new %s()" + """.formatted(MyUppercaseProcessor.class.getName(), MyUppercaseProcessor.class.getName(), + MyUppercaseProcessor.class.getName())); + + withMock("mock:result", mock -> { + mock.expectedMessageCount(2); + mock.expectedBodiesReceived("HELLO", "WORLD"); + }); + + context.start(); + + withTemplate(t -> { + t.to("direct:foo").withBody("hello").send(); + t.to("direct:foo2").withBody("world").send(); + }); + + assertThat(context.getRouteTemplateDefinitions().size()).isEqualTo(1); + assertThat(context.getRouteDefinitions().size()).isEqualTo(2); + + RouteDefinition route1 = context.getRouteDefinitions().get(0); + assertThat(route1.getRouteId()).isEqualTo("myRoute"); + assertThat(route1.getGroup()).isEqualTo("myGroup"); + + RouteDefinition route2 = context.getRouteDefinitions().get(1); + assertThat(route2.getRouteId()).isEqualTo("myRoute2"); + assertThat(route2.getGroup()).isEqualTo("myGroup"); + + MockEndpoint.assertIsSatisfied(context); + } + @Test void createTemplatedRoute() throws Exception { loadRoutes(""" From 231abcbf66a483e3e29ae5e7b0f8ba0ffa86be4e Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:12:07 -0400 Subject: [PATCH 3/7] CAMEL-24704: camel-jbang - bean reference checks see beans written as a map BeanRefChecks.declaredBeans scanned only the canonical `- name: x` list form under `beans:`, so a bean written as a map keyed by its name (supported by the runtime and schema since earlier CAMEL-24704 commits) was reported as an undeclared reference. The line scanner now also recognizes the first indent level under a beans: block as a map key (the bean name) when the line is not a list item, ignoring comment lines so they neither set the child indent nor are read as names. The canonical list form is unaffected. _Claude Code on behalf of Adriano Machado (@ammachado)_ _This was generated by an AI agent and may contain inaccuracies. Please verify before relying on it._ Co-authored-by: Claude Opus 5.5 --- .../jbang/core/commands/ai/BeanRefChecks.java | 24 +++++++- .../ai/SourceValidatorBeanRefsTest.java | 59 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java index 6d29cd936d0b1..8c2788539e3c9 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java @@ -55,6 +55,9 @@ private BeanRefChecks() { static final Pattern BEAN_TYPE_PATTERN = Pattern.compile("^\\s*type:\\s*[\"']?#class:([\\w.$]+)"); + /** A bean written as a map: the name alone as the key, its properties indented below (CAMEL-24704). */ + static final Pattern MAP_KEY_PATTERN = Pattern.compile("^(\"[^\"]+\"|'[^']+'|[^\\s:#\"'][^:#]*?):\\s*$"); + /** The beans declared under {@code beans:} with a {@code #class:} type, name to fully qualified class name. */ static Map declaredBeanTypes(String content) { Map types = new LinkedHashMap<>(); @@ -127,7 +130,10 @@ static Pattern beanRefPattern(CamelCatalog catalog) { }); } - /** The bean names declared under {@code beans:} in the YAML content. */ + /** + * The bean names declared under {@code beans:} in the YAML content: the canonical list ({@code - name: x}) and, + * since CAMEL-24704, a map keyed by bean name. + */ public static Set declaredBeans(String content) { Set names = new HashSet<>(); if (content == null) { @@ -135,6 +141,7 @@ public static Set declaredBeans(String content) { } String[] lines = content.split("\n", -1); int blockIndent = -1; + int childIndent = -1; for (String line : lines) { if (line.isBlank()) { continue; @@ -143,13 +150,28 @@ public static Set declaredBeans(String content) { int indent = countLeadingSpaces(line); if (blockIndent >= 0 && indent <= blockIndent) { blockIndent = -1; + childIndent = -1; } if (blockIndent < 0) { if (trimmed.equals("- beans:") || trimmed.equals("beans:")) { blockIndent = indent; + childIndent = -1; } continue; } + if (trimmed.startsWith("#")) { + continue; + } + if (childIndent < 0) { + childIndent = indent; + } + if (indent == childIndent && !trimmed.startsWith("-")) { + Matcher km = MAP_KEY_PATTERN.matcher(trimmed); + if (km.find()) { + names.add(unquote(km.group(1))); + continue; + } + } Matcher m = BEAN_NAME_PATTERN.matcher(line); if (m.find()) { names.add(unquote(m.group(1))); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java index aa5a2dbcb8a67..35d134f4eed09 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java @@ -420,6 +420,65 @@ public int leak() { .contains("has no package").doesNotContain("//DEPS"); } + /** CAMEL-24704: beans written as a map keyed by name declare their names too. */ + @Test + void declaredAsAMapIsFine(@TempDir Path dir) throws IOException { + // the type: line is checked against the classpath/directory regardless of list or map form (unrelated to + // this task): give it a real sibling class so only the map-form declaredBeans fix is under test here. + Files.writeString(dir.resolve("MyAggregator.java"), """ + package com.example; + public class MyAggregator { + } + """); + String declared = """ + - beans: + myAggregator: + type: "#class:com.example.MyAggregator" + properties: + other: x + """ + ROUTE; + assertThat(SourceValidator.declaredBeans(declared)).containsExactly("myAggregator"); + assertThat(SourceValidator.validateYamlBeanRefs(declared, + SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"), CATALOG)).isEmpty(); + } + + /** CAMEL-24704: a nested beans: (routeTemplate) is found by indentation alone, list or map form alike. */ + @Test + void declaredNestedInARouteTemplateIsFine() { + String listForm = """ + - routeTemplate: + id: myTemplate + beans: + - name: myAggregator + type: "#class:com.example.MyAggregator" + """; + assertThat(SourceValidator.declaredBeans(listForm)).containsExactly("myAggregator"); + + String mapForm = """ + - routeTemplate: + id: myTemplate + beans: + myAggregator: + type: "#class:com.example.MyAggregator" + """; + assertThat(SourceValidator.declaredBeans(mapForm)).containsExactly("myAggregator"); + } + + /** CAMEL-24704: a comment before the first map-form bean neither sets the child indent nor is read as a name. */ + @Test + void aCommentBeforeAMapFormBeanIsIgnored() { + String content = """ + - beans: + # a differently indented comment must not set the child indent + myAggregator: + type: "#class:com.example.MyAggregator" + # a comment between beans, at the child indent + other: + type: "#class:com.example.Other" + """; + assertThat(SourceValidator.declaredBeans(content)).containsExactlyInAnyOrder("myAggregator", "other"); + } + @Test void anInnerClassAsABeanTypeIsNamed(@TempDir Path dir) throws IOException { Files.writeString(dir.resolve("Sim.java"), "public class Sim { public static class Leak {} }\n"); From d2e1c7fa0684a635a7ccb939c9a66359e948a0fb Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:18:16 -0400 Subject: [PATCH 4/7] CAMEL-24704: camel-jbang - bean type checks see beans written as a map declaredBeanTypes bound each type: line to the last `- name: x` it saw, with no awareness of a beans: block's boundaries or of the map-keyed form added earlier in CAMEL-24704: a map-form bean's type was silently attributed to an earlier, unrelated list-form bean name (or the last name from a previous beans: block), so the interface check for options like aggregationStrategy/idempotentRepository could miss a real mismatch or misreport it against the wrong bean. Both declaredBeans and declaredBeanTypes now share a single scanBeansBlocks walk of each beans: block (list or map form, comments ignored), which also resets "the last bean seen" whenever a block closes, so a type: line outside any bean, or in a later block, is never attributed to a stale name. _Claude Code on behalf of Adriano Machado (@ammachado)_ _This was generated by an AI agent and may contain inaccuracies. Please verify before relying on it._ Co-authored-by: Claude Opus 5.5 --- .../jbang/core/commands/ai/BeanRefChecks.java | 125 +++++++++++------- .../ai/SourceValidatorBeanRefsTest.java | 53 +++++++- 2 files changed, 125 insertions(+), 53 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java index 8c2788539e3c9..5aef3d41aebd4 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java @@ -31,6 +31,7 @@ import java.util.Properties; import java.util.Set; import java.util.TreeSet; +import java.util.function.BiConsumer; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -58,25 +59,82 @@ private BeanRefChecks() { /** A bean written as a map: the name alone as the key, its properties indented below (CAMEL-24704). */ static final Pattern MAP_KEY_PATTERN = Pattern.compile("^(\"[^\"]+\"|'[^']+'|[^\\s:#\"'][^:#]*?):\\s*$"); - /** The beans declared under {@code beans:} with a {@code #class:} type, name to fully qualified class name. */ + /** + * The beans declared under {@code beans:} with a {@code #class:} type, name to fully qualified class name: the + * canonical list ({@code - name: x}) and, since CAMEL-24704, a map keyed by bean name. + */ static Map declaredBeanTypes(String content) { Map types = new LinkedHashMap<>(); + String[] last = new String[1]; + scanBeansBlocks(content, (line, name) -> { + if (name != null) { + last[0] = name; + return; + } + Matcher tm = BEAN_TYPE_PATTERN.matcher(line); + if (tm.find() && last[0] != null) { + types.put(last[0], tm.group(1)); + } + }, () -> last[0] = null); + return types; + } + + /** + * Walks the lines of every {@code beans:} block (the canonical list, {@code - name: x}, and, since CAMEL-24704, a + * map keyed by bean name), calling {@code onLine} with the raw line and the bean name it starts (null when the line + * does not start one), and {@code onBlockEnd} whenever a block closes (including once at the end of the content, if + * a block is still open when it ends) so a caller tracking "the last bean seen" can reset it there: a type: line + * outside any bean, or in a later, unrelated block, must never be attributed to a name from a previous block. + */ + private static void scanBeansBlocks(String content, BiConsumer onLine, Runnable onBlockEnd) { if (content == null) { - return types; + return; } - String last = null; - for (String line : content.split("\n", -1)) { - Matcher nm = BEAN_NAME_PATTERN.matcher(line); - if (nm.find()) { - last = unquote(nm.group(1)); + String[] lines = content.split("\n", -1); + int blockIndent = -1; + int childIndent = -1; + for (String line : lines) { + if (line.isBlank()) { continue; } - Matcher tm = BEAN_TYPE_PATTERN.matcher(line); - if (tm.find() && last != null) { - types.put(last, tm.group(1)); + String trimmed = line.trim(); + int indent = countLeadingSpaces(line); + if (blockIndent >= 0 && indent <= blockIndent) { + blockIndent = -1; + childIndent = -1; + onBlockEnd.run(); + } + if (blockIndent < 0) { + if (trimmed.equals("- beans:") || trimmed.equals("beans:")) { + blockIndent = indent; + childIndent = -1; + } + continue; + } + if (trimmed.startsWith("#")) { + continue; + } + if (childIndent < 0) { + childIndent = indent; + } + String name = null; + if (indent == childIndent && !trimmed.startsWith("-")) { + Matcher km = MAP_KEY_PATTERN.matcher(trimmed); + if (km.find()) { + name = unquote(km.group(1)); + } + } + if (name == null) { + Matcher m = BEAN_NAME_PATTERN.matcher(line); + if (m.find()) { + name = unquote(m.group(1)); + } } + onLine.accept(line, name); + } + if (blockIndent >= 0) { + onBlockEnd.run(); } - return types; } private static final Map> REQUIRED_TYPES_BY_CATALOG @@ -136,47 +194,12 @@ static Pattern beanRefPattern(CamelCatalog catalog) { */ public static Set declaredBeans(String content) { Set names = new HashSet<>(); - if (content == null) { - return names; - } - String[] lines = content.split("\n", -1); - int blockIndent = -1; - int childIndent = -1; - for (String line : lines) { - if (line.isBlank()) { - continue; - } - String trimmed = line.trim(); - int indent = countLeadingSpaces(line); - if (blockIndent >= 0 && indent <= blockIndent) { - blockIndent = -1; - childIndent = -1; - } - if (blockIndent < 0) { - if (trimmed.equals("- beans:") || trimmed.equals("beans:")) { - blockIndent = indent; - childIndent = -1; - } - continue; - } - if (trimmed.startsWith("#")) { - continue; - } - if (childIndent < 0) { - childIndent = indent; + scanBeansBlocks(content, (line, name) -> { + if (name != null) { + names.add(name); } - if (indent == childIndent && !trimmed.startsWith("-")) { - Matcher km = MAP_KEY_PATTERN.matcher(trimmed); - if (km.find()) { - names.add(unquote(km.group(1))); - continue; - } - } - Matcher m = BEAN_NAME_PATTERN.matcher(line); - if (m.find()) { - names.add(unquote(m.group(1))); - } - } + }, () -> { + }); return names; } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java index 35d134f4eed09..908bf61ea5366 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java @@ -424,10 +424,13 @@ public int leak() { @Test void declaredAsAMapIsFine(@TempDir Path dir) throws IOException { // the type: line is checked against the classpath/directory regardless of list or map form (unrelated to - // this task): give it a real sibling class so only the map-form declaredBeans fix is under test here. + // this task), and, since declaredBeanTypes now also sees the map form, so is the aggregationStrategy + // reference's interface: give it a real sibling class that implements AggregationStrategy so only the + // map-form declaredBeans fix is under test here. Files.writeString(dir.resolve("MyAggregator.java"), """ package com.example; - public class MyAggregator { + import org.apache.camel.AggregationStrategy; + public class MyAggregator implements AggregationStrategy { } """); String declared = """ @@ -479,6 +482,52 @@ void aCommentBeforeAMapFormBeanIsIgnored() { assertThat(SourceValidator.declaredBeans(content)).containsExactlyInAnyOrder("myAggregator", "other"); } + /** + * CAMEL-24704: declaredBeanTypes must not attribute a map-form bean's type to the last list-form name it saw (or + * vice versa): a list-form bean followed by a routeTemplate with a map-form bean, each with its own type, must keep + * each type bound to its own name, and the interface check must name the right one. + */ + @Test + void declaredBeanTypesSeeTheMapFormToo(@TempDir Path dir) throws IOException { + Files.writeString(dir.resolve("MyRepoA.java"), """ + package com.example; + public class MyRepoA { + } + """); + Files.writeString(dir.resolve("MyRepoB.java"), """ + package com.example; + public class MyRepoB { + } + """); + String yaml = """ + - beans: + - name: repoA + type: "#class:com.example.MyRepoA" + - routeTemplate: + id: t + beans: + repoB: + type: "#class:com.example.MyRepoB" + route: + from: + uri: timer:tick + steps: + - idempotentConsumer: + simple: "${header.id}" + idempotentRepository: repoB + steps: + - log: hi + """; + assertThat(BeanRefChecks.declaredBeanTypes(yaml)).hasSize(2) + .containsEntry("repoA", "com.example.MyRepoA") + .containsEntry("repoB", "com.example.MyRepoB"); + List msgs = SourceValidator.validateYamlBeanRefs(yaml, + SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"), CATALOG); + assertThat(msgs).hasSize(1); + assertThat(msgs.get(0)) + .contains("com.example.MyRepoB must implement org.apache.camel.spi.IdempotentRepository"); + } + @Test void anInnerClassAsABeanTypeIsNamed(@TempDir Path dir) throws IOException { Files.writeString(dir.resolve("Sim.java"), "public class Sim { public static class Leak {} }\n"); From cbab5dde8a084405b9b2b707a004b7660ebdf8f6 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:29:45 -0400 Subject: [PATCH 5/7] CAMEL-24704: camel-yaml-dsl - document beans written as a map yaml-dsl.adoc gains a NOTE next to the first beans example showing the map form (beans keyed by name, without name:), and stating that the list is the canonical form written by camel validate yaml --canonical, the canonical schema and the YAML Camel writes. The 4.23 upgrade guide documents the schema change for tools that read camelYamlDsl.json (beans is now oneOf a list or a map keyed by name, with the new BeanFactoryDefinitionByName definition); the canonical schema is unchanged. The catalog's mirrored copy of yaml-dsl.adoc is regenerated to match (catalog/camel-catalog -Dquickly). docs/components/modules/others/pages/yaml-dsl.adoc is a symlink to the source doc, so it needs no separate change. _Claude Code on behalf of Adriano Machado (@ammachado)_ _This was generated by an AI agent and may contain inaccuracies. Please verify before relying on it._ Co-authored-by: Claude Opus 5.5 --- .../org/apache/camel/catalog/docs/yaml-dsl.adoc | 17 +++++++++++++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 8 ++++++++ .../camel-yaml-dsl/src/main/docs/yaml-dsl.adoc | 17 +++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc index 1a0fe5a248857..629dfb4b9ec8a 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc @@ -365,6 +365,23 @@ In addition to the general support for creating beans provided by xref:others:ma <2> the full qualified class name of the bean <3> the properties of the bean to be set +[NOTE] +==== +The beans can also be written as a map keyed by the bean name, without `name:`: + +[source,yaml] +---- +- beans: + beanFromMap: + type: com.acme.MyBean + properties: + foo: bar +---- + +The list shown above is the canonical form: `camel validate yaml --canonical`, the canonical schema and the +YAML Camel writes all use the list. +==== + A bean declared this way is bound to the Camel registry, so a route in the same file can refer to it by name, such as calling it with the xref:eips:bean-eip.adoc[Bean] EIP: diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index 4870b4b1242d5..6df8cd230006a 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -1276,6 +1276,14 @@ becomes: uri: log:result ---- +=== camel-yaml-dsl - the schema accepts beans written as a map + +The JSON schema `camelYamlDsl.json` now describes `beans` (at the top level, in `routeTemplate` and in +`templatedRoute`) as `oneOf` a list or a map keyed by the bean name, with the new definition +`org.apache.camel.model.BeanFactoryDefinitionByName` for the map values. Tools that read the schema +(such as editors) and expect `beans` to be `type: array` need to handle both. The canonical schema +`camelYamlDsl-canonical.json` is unchanged. + === camel-mail `MimeMultipartDataFormat` now uses `MailHeaderFilterStrategy` instead of a plain 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 1a0fe5a248857..629dfb4b9ec8a 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 @@ -365,6 +365,23 @@ In addition to the general support for creating beans provided by xref:others:ma <2> the full qualified class name of the bean <3> the properties of the bean to be set +[NOTE] +==== +The beans can also be written as a map keyed by the bean name, without `name:`: + +[source,yaml] +---- +- beans: + beanFromMap: + type: com.acme.MyBean + properties: + foo: bar +---- + +The list shown above is the canonical form: `camel validate yaml --canonical`, the canonical schema and the +YAML Camel writes all use the list. +==== + A bean declared this way is bound to the Camel registry, so a route in the same file can refer to it by name, such as calling it with the xref:eips:bean-eip.adoc[Bean] EIP: From 7b3a7395db1a87b42009bced680a1f820085dd31 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:36:55 -0400 Subject: [PATCH 6/7] CAMEL-24704: camel-yaml-dsl - the beans map form example is a fragment, not a sample GenerateDocSamplesMojo treats every [source,yaml] block of yaml-dsl.adoc whose first line starts with "- " as a route example: it is validated and, for yaml-dsl.adoc, sampled as a "beans" entry served to AI models by camel_catalog_sample. The map-form example added for CAMEL-24704 started with "- beans:", so it became the 2nd beans sample and displaced the canonical "declare a bean and call it from a route" sample that CatalogSamplesTest.beansIsATopLevelEntryWithASample expects at index 1. Drop the leading "- " so the example is a fragment (the value of `beans:`, not a full route): the mojo then neither validates nor samples it, and models keep being taught the canonical list form. Also renamed the example bean to myMapBean so it no longer reuses beanFromMap from the list example right above it, and added a short phrase to the NOTE clarifying the fragment is the value of `beans:`. Regenerated the catalog's mirrored copy of yaml-dsl.adoc (catalog/camel-catalog -Dquickly). Rebuilt camel-jbang-core (-Dquickly) and confirmed eip-samples.json is unchanged from HEAD. _Claude Code on behalf of Adriano Machado (@ammachado)_ _This was generated by an AI agent and may contain inaccuracies. Please verify before relying on it._ Co-authored-by: Claude Opus 5.5 --- .../org/apache/camel/catalog/docs/yaml-dsl.adoc | 12 ++++++------ .../camel-yaml-dsl/src/main/docs/yaml-dsl.adoc | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc index 629dfb4b9ec8a..654863ed87cfb 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc @@ -367,15 +367,15 @@ In addition to the general support for creating beans provided by xref:others:ma [NOTE] ==== -The beans can also be written as a map keyed by the bean name, without `name:`: +The beans can also be written as a map keyed by the bean name, without `name:`, as the value of `beans:`: [source,yaml] ---- -- beans: - beanFromMap: - type: com.acme.MyBean - properties: - foo: bar +beans: + myMapBean: + type: com.acme.MyBean + properties: + foo: bar ---- The list shown above is the canonical form: `camel validate yaml --canonical`, the canonical schema and the 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 629dfb4b9ec8a..654863ed87cfb 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 @@ -367,15 +367,15 @@ In addition to the general support for creating beans provided by xref:others:ma [NOTE] ==== -The beans can also be written as a map keyed by the bean name, without `name:`: +The beans can also be written as a map keyed by the bean name, without `name:`, as the value of `beans:`: [source,yaml] ---- -- beans: - beanFromMap: - type: com.acme.MyBean - properties: - foo: bar +beans: + myMapBean: + type: com.acme.MyBean + properties: + foo: bar ---- The list shown above is the canonical form: `camel validate yaml --canonical`, the canonical schema and the From 2b2e44f534c6ace368018c69d2808f0794d32c5a Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:55:51 -0400 Subject: [PATCH 7/7] CAMEL-24704: camel-yaml-dsl - fix final review findings on the beans map form F1: the missing-dash list-form mistake (- forgotten before name: myBean) was read as beans: {name: myBean, type: ...} and lost its hint, both in the lenient validator ("string found, object expected" with no hint) and at runtime (the misleading "a bean written as a map is name: ..." message). Add a validator hint for .*/beans/name "object expected" errors, and have BeansDeserializer.asBeanDefinitions recognize a "name" key with a non-mapping value as this same mistake. F2: GenerateYamlSchemaMojo.generateKeyedDefinitions and allowMapForm used withObject(), which silently creates an empty node for a renamed or missing item definition, or lets a second mapKey overwrite the first for the same item type. Fail the build loudly in both cases instead. F3: the id/ref/class unknownProperty hint only matched the list form's numeric index (/\d+/beans/\d+); extend it to also match a bean written as a map keyed by its name (/\d+/beans/[^/]+), while keeping the "a bean item is written as - name: ..." hint restricted to the list form, where an unknown key does mean the item is keyed by name. F4: clarify YamlProperty.mapKey()'s Javadoc: only the YAML schema generator reads it, so the deserializer of the property that carries it must accept the map form itself. _Claude Code on behalf of Adriano Machado (@ammachado)_ _This was generated by an AI agent and may contain inaccuracies. Please verify before relying on it._ Co-authored-by: Claude Opus 5.5 --- .../camel/spi/annotations/YamlProperty.java | 4 ++- .../yaml/deserializers/BeansDeserializer.java | 8 +++++ .../dsl/yaml/GenerateYamlSchemaMojo.java | 26 +++++++++++---- .../camel/dsl/yaml/validator/SchemaHints.java | 11 +++++-- .../YamlValidatorPropertyHintTest.java | 32 +++++++++++++++++++ .../org/apache/camel/dsl/yaml/BeansTest.java | 11 +++++++ .../camel/spi/annotations/YamlProperty.java | 4 ++- 7 files changed, 85 insertions(+), 11 deletions(-) diff --git a/core/camel-api/src/generated/java/org/apache/camel/spi/annotations/YamlProperty.java b/core/camel-api/src/generated/java/org/apache/camel/spi/annotations/YamlProperty.java index 40a36465587a7..1431f7f9078d0 100644 --- a/core/camel-api/src/generated/java/org/apache/camel/spi/annotations/YamlProperty.java +++ b/core/camel-api/src/generated/java/org/apache/camel/spi/annotations/YamlProperty.java @@ -48,7 +48,9 @@ /** * The name of the property that identifies an item of this list (such as name). When set, the list may also be - * written as a map from that property to the rest of the item, which the non-canonical schema accepts. + * written as a map from that property to the rest of the item, which the non-canonical schema accepts. Only the + * YAML schema generator reads this attribute: the deserializer of the property that carries it must itself accept + * the map form (as BeansDeserializer.asBeanDefinitions does for beans:). * * @since 4.23 */ 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 3693b356504bd..c60166766ee11 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 @@ -121,6 +121,14 @@ public static List> asBeanDefinitions(Node node) { throw new YamlDeserializationException(entry.getKeyNode(), "the bean " + name + " is declared twice"); } if (value.getNodeType() != NodeType.MAPPING) { + if ("name".equals(name)) { + // the "- " of the list form was forgotten: beans: {name: myBean, type: ...} reads as a map + // whose first key is "name", not a bean named "name" (CAMEL-24704 F1) + throw new YamlDeserializationException( + entry.getKeyNode(), + "beans is a list: - name: myBean followed by type: \"#class:com.example.MyBean\"" + + " (indented under the -)"); + } throw new YamlDeserializationException( value, "a bean written as a map is " + name diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java index 4bc940298061e..f62671e428b56 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java @@ -188,7 +188,8 @@ protected void generate() throws MojoFailureException { } } - private void generate(String type, ClassInfo info, Set inheritedDefinitions, Set inlineDefinitions) { + private void generate(String type, ClassInfo info, Set inheritedDefinitions, Set inlineDefinitions) + throws MojoFailureException { final ObjectNode definition = definitions.withObject("/" + type); final List properties = new ArrayList<>(); @@ -550,7 +551,7 @@ private void setProperty( * A list whose items are identified by a property (a bean by its name) may also be written as a map from that * property to the rest of the item: beans: {myBean: {type: ...}} (CAMEL-24704). */ - private void allowMapForm(ObjectNode schema, String itemType, String mapKey) { + private void allowMapForm(ObjectNode schema, String itemType, String mapKey) throws MojoFailureException { schema.remove("type"); JsonNode items = schema.remove("items"); ArrayNode oneOf = schema.withArray("oneOf"); @@ -558,7 +559,11 @@ private void allowMapForm(ObjectNode schema, String itemType, String mapKey) { oneOf.addObject().put("type", "object") .withObject("/additionalProperties") .put("$ref", "#/items/definitions/" + keyedName(itemType, mapKey)); - keyedDefinitions.put(itemType, mapKey); + String existingMapKey = keyedDefinitions.put(itemType, mapKey); + if (existingMapKey != null && !existingMapKey.equals(mapKey)) { + throw new MojoFailureException( + itemType + " is already keyed by " + existingMapKey + ", it cannot also be keyed by " + mapKey); + } } private static String keyedName(String itemType, String mapKey) { @@ -566,9 +571,16 @@ private static String keyedName(String itemType, String mapKey) { } /** The item definition of the map form: the item's own definition without the key property. */ - private void generateKeyedDefinitions() { - keyedDefinitions.forEach((itemType, mapKey) -> { - ObjectNode keyed = definitions.withObject("/" + itemType).deepCopy(); + private void generateKeyedDefinitions() throws MojoFailureException { + for (Map.Entry entry : keyedDefinitions.entrySet()) { + String itemType = entry.getKey(); + String mapKey = entry.getValue(); + JsonNode existing = definitions.get(itemType); + if (existing == null) { + throw new MojoFailureException( + "No definition found for " + itemType + ", the item type of the map keyed by " + mapKey); + } + ObjectNode keyed = ((ObjectNode) existing).deepCopy(); keyed.withObject("/properties").remove(mapKey); if (keyed.has("required")) { ArrayNode required = keyed.withArray("required"); @@ -582,7 +594,7 @@ private void generateKeyedDefinitions() { } } definitions.set(keyedName(itemType, mapKey), keyed); - }); + } } private void collectYamlProperties(List annotations, ClassInfo info) { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java index a8b2e96b09fe2..5d2ebaf7ca38c 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java @@ -263,6 +263,12 @@ static Error apply(List table, Error error, YamlValidator validator) { append("type", ".*/beans", m -> m.message().contains("array expected"), m -> "beans is a list: - name: myBean followed by type: \"#class:com.example.MyBean\" (indented" + " under the -)"), + // beans:\n name: myBean\n type: ...: the "- " of the list form was forgotten; the map form's first + // key happens to be "name", read as a bean property rather than the map key (CAMEL-24704 F1) + append("type", ".*/beans/name", m -> m.message().contains("object expected"), + m -> "beans is a list: each bean starts with \"- \": - name: myBean followed by type:" + + " \"#class:com.example.MyBean\" (indented under the -); or a map keyed by the bean name:" + + " myBean: {type: ...}"), append("type", ".*/(steps|when|get|post|exception|doCatch)", m -> m.message().contains("array expected"), m -> m.name() + " is a list: each item starts with \"- \""), append("type", null, m -> m.message().contains("array expected"), @@ -291,8 +297,9 @@ static Error apply(List table, Error error, YamlValidator validator) { append("required", ".*/route/from", m -> m.message().contains("required property 'steps' not found"), m -> "steps: is a property of from:, next to uri:; a steps: written at the route level must be" + " indented under from:"), - // - id: myBean / class: ... : the bean properties are name and type - unknownProperty("/\\d+/beans/\\d+", m -> Set.of("id", "ref", "class").contains(m.unknown()), + // - id: myBean / class: ... , or myBean: {class: ...} in the map form: the bean properties are name + // and type (CAMEL-24704 F3: the map form's item is keyed by name, not by an index) + unknownProperty("/\\d+/beans/[^/]+", m -> Set.of("id", "ref", "class").contains(m.unknown()), m -> "a bean is - name: myBean followed by type: \"#class:com.example.MyBean\" (name instead of " + m.unknown() + (m.unknown().equals("class") ? ", type instead of class" : "") + ")"), // - myBean: {type: ...} instead of - name: myBean / type: ... diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java index 5740a7a54008d..fc65ec9746d8a 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java @@ -402,6 +402,22 @@ public void testMapWhereAListIsExpectedSaysHowToWriteIt() throws Exception { assertThat(errors).anyMatch(e -> e.getMessage().contains("steps is a list")); } + /** CAMEL-24704 F1: the list form with the "- " forgotten reads as a map whose first key is "name". */ + @Test + public void testMissingDashOnListFormBeanSaysBeansIsAList() throws Exception { + List errors = validator.validate(""" + - beans: + name: myBean + type: "#class:com.example.MyBean" + - from: + uri: timer:tick + steps: + - log: + message: "hi" + """); + assertThat(errors).anyMatch(e -> e.getMessage().contains("beans is a list")); + } + @Test public void testBeanItemKeyedByNameSaysHowToWriteIt() throws Exception { List errors = validator.validate(""" @@ -877,6 +893,22 @@ public void testBeanWithIdInsteadOfNameSaysSo() throws Exception { assertThat(errors).anyMatch(e -> e.getMessage().contains("name instead of id")); } + /** CAMEL-24704 F3: the id/ref/class hint also fires for a bean written as a map keyed by name. */ + @Test + public void testBeanAsMapWithClassInsteadOfTypeSaysSo() throws Exception { + List errors = validator.validate(""" + - beans: + myBean: + class: com.example.MyBean + - from: + uri: timer:tick + steps: + - log: + message: "hi" + """); + assertThat(errors).anyMatch(e -> e.getMessage().contains("type instead of class")); + } + @Test public void testEmptyFileSaysWhatAFileIs() throws Exception { List errors = validator.validate("# nothing here yet\n"); diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/BeansTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/BeansTest.java index c69a6b8658232..9f83e5d5f30ed 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/BeansTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/BeansTest.java @@ -458,6 +458,17 @@ void beansAsMapWithoutPropertiesFails() { assertThat(messages(e)).contains("myBean").contains("indented under the name"); } + @Test + void beansAsMapMissingDashOnListFormFails() { + // CAMEL-24704 F1: the list form with the "- " forgotten reads as a map whose first key is "name" + Exception e = assertThrows(Exception.class, () -> loadRoutesNoValidate(""" + - beans: + name: myBean + type: %s + """.formatted(MyBean.class.getName()))); + assertThat(messages(e)).contains("beans is a list").contains("- name: myBean"); + } + @Test void beansAsMapWithDuplicateNameFails() { Exception e = assertThrows(Exception.class, () -> loadRoutesNoValidate(""" diff --git a/tooling/spi-annotations/src/main/java/org/apache/camel/spi/annotations/YamlProperty.java b/tooling/spi-annotations/src/main/java/org/apache/camel/spi/annotations/YamlProperty.java index 40a36465587a7..1431f7f9078d0 100644 --- a/tooling/spi-annotations/src/main/java/org/apache/camel/spi/annotations/YamlProperty.java +++ b/tooling/spi-annotations/src/main/java/org/apache/camel/spi/annotations/YamlProperty.java @@ -48,7 +48,9 @@ /** * The name of the property that identifies an item of this list (such as name). When set, the list may also be - * written as a map from that property to the rest of the item, which the non-canonical schema accepts. + * written as a map from that property to the rest of the item, which the non-canonical schema accepts. Only the + * YAML schema generator reads this attribute: the deserializer of the property that carries it must itself accept + * the map form (as BeansDeserializer.asBeanDefinitions does for beans:). * * @since 4.23 */