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..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 @@ -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:`, as the value of `beans:`: + +[source,yaml] +---- +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 +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/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..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 @@ -45,4 +45,14 @@ 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. 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 + */ + String mapKey() default ""; } 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-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..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; @@ -55,25 +56,85 @@ private BeanRefChecks() { static final Pattern BEAN_TYPE_PATTERN = Pattern.compile("^\\s*type:\\s*[\"']?#class:([\\w.$]+)"); - /** The beans declared under {@code beans:} with a {@code #class:} type, name to fully qualified class name. */ + /** 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 + * 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 @@ -127,34 +188,18 @@ 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) { - return names; - } - String[] lines = content.split("\n", -1); - int blockIndent = -1; - for (String line : lines) { - if (line.isBlank()) { - continue; + scanBeansBlocks(content, (line, name) -> { + if (name != null) { + names.add(name); } - String trimmed = line.trim(); - int indent = countLeadingSpaces(line); - if (blockIndent >= 0 && indent <= blockIndent) { - blockIndent = -1; - } - if (blockIndent < 0) { - if (trimmed.equals("- beans:") || trimmed.equals("beans:")) { - blockIndent = indent; - } - 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 aa5a2dbcb8a67..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 @@ -420,6 +420,114 @@ 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), 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; + import org.apache.camel.AggregationStrategy; + public class MyAggregator implements AggregationStrategy { + } + """); + 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"); + } + + /** + * 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"); 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..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 @@ -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( @@ -48,7 +54,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 { @@ -57,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()); } @@ -96,6 +99,64 @@ 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) { + 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 + + ": 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 2322b47349f3e..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 @@ -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 { @@ -101,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 93f4f62a4cbcf..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 @@ -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 { @@ -89,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-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..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 @@ -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); @@ -183,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<>(); @@ -251,6 +257,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 +299,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 +354,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 +547,56 @@ 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) throws MojoFailureException { + 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)); + 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) { + 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() 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"); + 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/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/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..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 @@ -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 @@ -394,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(""" @@ -869,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/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/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..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 @@ -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:`, as the value of `beans:`: + +[source,yaml] +---- +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 +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/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..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 @@ -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,79 @@ 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 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(""" + - 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(""" 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..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 @@ -45,4 +45,14 @@ 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. 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 + */ + String mapKey() default ""; }