Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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*$");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively (or as well), this could recognize a flow-mapping bean at the child indent (myBean: {type: "#class:..."}) so the reference and type checks match what the deserializer accepts. A test with the flow form would cover it.


/**
* 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<String, String> declaredBeanTypes(String content) {
Map<String, String> 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<String, String> 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<CamelCatalog, Map<String, String>> REQUIRED_TYPES_BY_CATALOG
Expand Down Expand Up @@ -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<String> declaredBeans(String content) {
Set<String> 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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");
Expand Down
Loading
Loading