diff --git a/annot/src/main/java/com/predic8/membrane/annot/MCElement.java b/annot/src/main/java/com/predic8/membrane/annot/MCElement.java
index 64ba5a8293..ec3efbd8cf 100644
--- a/annot/src/main/java/com/predic8/membrane/annot/MCElement.java
+++ b/annot/src/main/java/com/predic8/membrane/annot/MCElement.java
@@ -69,7 +69,14 @@
* Whether the element has only one attribute.
* Enables inline yaml object configuration
* e.g.
+ *
* allow: foo
+ *
+ * instead of
+ *
+ * allow:
+ * value: foo
+ *
*/
- boolean singleAttribute() default false;
+ boolean collapsed() default false;
}
diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java b/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java
index 5543a47064..b28f11570f 100644
--- a/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java
+++ b/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java
@@ -110,7 +110,7 @@ private void addParserDefinitions(Model m, MainInfo main) {
}
}
- private SchemaObject createParser(Model m, MainInfo main, ElementInfo elementInfo) {
+ private AbstractSchema> createParser(Model m, MainInfo main, ElementInfo elementInfo) {
String parserName = elementInfo.getXSDTypeName(m);
if (isComponentsMap(elementInfo)) {
@@ -133,6 +133,14 @@ private SchemaObject createParser(Model m, MainInfo main, ElementInfo elementInf
return ref(parserName).ref("#/$defs/%sParser".formatted(childName));
}
+ // enforce the inline form for collapsed elements
+ if (elementInfo.getAnnotation().collapsed()) {
+ if (elementInfo.getAnnotation().noEnvelope()) {
+ throw new ProcessingException("@MCElement(collapsed=true) is not compatible with noEnvelope=true.", elementInfo.getElement());
+ }
+ return createCollapsedInlineParser(elementInfo, parserName);
+ }
+
SchemaObject parser = getParserSchemaObject(elementInfo, parserName);
collectProperties(m, main, elementInfo, parser);
@@ -144,50 +152,53 @@ private SchemaObject createParser(Model m, MainInfo main, ElementInfo elementInf
.required(false));
}
- // allow scalar inline form for single-attribute elements
- if (elementInfo.getAnnotation().singleAttribute()) {
- return anyOf(List.of(
- parser,
- createSingleAttributeInlineVariant(elementInfo)
- )).name(parserName);
- }
-
return parser;
}
- private AbstractSchema> createSingleAttributeInlineVariant(ElementInfo ei) {
- // Only count attributes that are actually part of the schema.
- var attrs = ei.getAis().stream()
- .filter(ai -> !ai.excludedFromJsonSchema())
- .toList();
+ private AbstractSchema> createCollapsedInlineParser(ElementInfo ei, String parserName) {
+ var attrs = ei.getAis().stream().toList();
+
+ boolean hasText = ei.getTci() != null;
+ boolean hasChildren = !ei.getChildElementSpecs().isEmpty();
- if (attrs.size() != 1) {
- throw new ProcessingException(
- "@MCElement(singleAttribute=true) requires exactly one @MCAttribute.",
- ei.getElement()
- );
+ if (hasChildren) {
+ throw new ProcessingException("@MCElement(collapsed=true) must not declare child elements.", ei.getElement());
}
- if (ei.getTci() != null || !ei.getChildElementSpecs().isEmpty()) {
- throw new ProcessingException(
- "@MCElement(singleAttribute=true) must not declare text content or child elements.",
- ei.getElement()
- );
+ if (hasText && !attrs.isEmpty()) {
+ throw new ProcessingException("@MCElement(collapsed=true) must not mix @MCTextContent with @MCAttribute.", ei.getElement());
}
- AttributeInfo ai = attrs.getFirst();
- String type = ai.getSchemaType(processingEnv.getTypeUtils());
+ // collapsed via single @MCTextContent -> scalar string
+ if (hasText) {
+ return SchemaFactory.from("string")
+ .name(parserName)
+ .type("string")
+ .description(getDescriptionContent(ei));
+ }
- AbstractSchema> s = SchemaFactory.from(type)
- .type(type)
- .description(getDescriptionContent(ai));
+ // collapsed via a single @MCAttribute-> scalar of that attribute type
+ if (attrs.size() == 1) {
+ AttributeInfo ai = attrs.getFirst();
+ String type = ai.getSchemaType(processingEnv.getTypeUtils());
- if (ai.isEnum(processingEnv.getTypeUtils()) && !"boolean".equals(type)) {
- s.enumValues(ai.enumsAsLowerCaseList(processingEnv.getTypeUtils()));
+ AbstractSchema> s = SchemaFactory.from(type)
+ .name(parserName)
+ .type(type)
+ .description(getDescriptionContent(ai).isEmpty() ? getDescriptionContent(ei) : getDescriptionContent(ai));
+
+ if (ai.isEnum(processingEnv.getTypeUtils()) && !"boolean".equals(type)) {
+ s.enumValues(ai.enumsAsLowerCaseList(processingEnv.getTypeUtils()));
+ }
+ return s;
}
- return s;
+ throw new ProcessingException(
+ "@MCElement(collapsed=true) requires exactly one @MCAttribute or exactly one @MCTextContent.",
+ ei.getElement()
+ );
}
+
private SchemaObject getParserSchemaObject(ElementInfo elementInfo, String parserName) {
return object(parserName)
.additionalProperties( elementInfo.isString())
diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/GenericYamlParser.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/GenericYamlParser.java
index 0af0405ed4..11f635af40 100644
--- a/annot/src/main/java/com/predic8/membrane/annot/yaml/GenericYamlParser.java
+++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/GenericYamlParser.java
@@ -25,6 +25,8 @@
import com.networknt.schema.SchemaRegistry;
import com.predic8.membrane.annot.Grammar;
import com.predic8.membrane.annot.MCAttribute;
+import com.predic8.membrane.annot.MCElement;
+import com.predic8.membrane.annot.MCTextContent;
import com.predic8.membrane.annot.beanregistry.BeanDefinition;
import com.predic8.membrane.annot.beanregistry.BeanLifecycleManager;
import com.predic8.membrane.annot.beanregistry.BeanRegistry;
@@ -38,6 +40,7 @@
import java.io.IOException;
import java.io.InputStream;
+import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
@@ -51,6 +54,7 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.List.of;
import static java.util.UUID.randomUUID;
+import static org.springframework.util.ReflectionUtils.doWithMethods;
public class GenericYamlParser {
private static final Logger log = LoggerFactory.getLogger(GenericYamlParser.class);
@@ -188,16 +192,18 @@ public static T createAndPopulateNode(ParsingContext> ctx, Class clazz,
return configObj;
}
- // scalar inline form for @MCElement(singleAttribute=true)
- if (!node.isObject()) {
- if (isSingleAttribute(clazz)) {
- applySingleAttributeScalar(clazz, node, configObj);
- return handlePostConstructAndPreDestroy(ctx, configObj);
+ // scalar inline form for @MCElement(collapsed=true)
+ if (isCollapsed(clazz)) {
+ if (node.isNull()) {
+ throw new ParsingException("Collapsed element must not be null.", node);
}
- // anything non-object is invalid here
- ensureMappingStart(node);
+ if (node.isArray() || node.isObject()) {
+ throw new ParsingException("Element is collapsed; expected an inline scalar value, not " +
+ (node.isArray() ? "an array" : "an object") + ".", node);
+ }
+ applyCollapsedScalar(clazz, node, configObj);
+ return handlePostConstructAndPreDestroy(ctx, configObj);
}
-
ensureMappingStart(node);
if (isNoEnvelope(clazz))
throw new RuntimeException("Class " + clazz.getName() + " is annotated with @MCElement(noEnvelope=true), but the YAML/JSON structure does not contain a list.");
@@ -343,7 +349,7 @@ private static T handlePostConstructAndPreDestroy(ParsingContext> ctx, T b
if (bean instanceof BeanRegistryAware beanRegistryAware) {
beanRegistryAware.setRegistry(ctx.registry());
}
- ReflectionUtils.doWithMethods(bean.getClass(), method -> {
+ doWithMethods(bean.getClass(), method -> {
if (method.isAnnotationPresent(PostConstruct.class)) {
try {
method.setAccessible(true);
@@ -362,19 +368,27 @@ private static T handlePostConstructAndPreDestroy(ParsingContext> ctx, T b
return bean;
}
- private static void applySingleAttributeScalar(Class clazz, JsonNode node, T target) {
+ private static void applyCollapsedScalar(Class clazz, JsonNode node, T target) {
if (node == null || node.isNull()) {
- throw new ParsingException("singleAttribute element must not be null.", node);
+ throw new ParsingException("Collapsed element must not be null.", node);
}
- Method setter = findSingleAttributeSetter(clazz);
+ Method attributeSetter = findSingleSetterOrNullForAnnotation(clazz, MCAttribute.class);
+ Method textSetter = findSingleSetterOrNullForAnnotation(clazz, MCTextContent.class);
+
+ if ((attributeSetter == null) == (textSetter == null)) {
+ // both null or both non-null -> invalid
+ throw new ParsingException("@MCElement(collapsed=true) requires exactly one @MCAttribute setter OR exactly one @MCTextContent setter.", node);
+ }
+ Method setter = (attributeSetter != null) ? attributeSetter : textSetter;
Class> paramType = setter.getParameterTypes()[0];
+
Object value;
try {
value = SCALAR_MAPPER.convertValue(node, paramType);
} catch (IllegalArgumentException e) {
- throw new ParsingException("Cannot convert scalar value to " + paramType.getSimpleName() + ".", node);
+ throw new ParsingException("Cannot convert inline value to " + paramType.getSimpleName() + ".", node);
}
try {
@@ -387,20 +401,22 @@ private static void applySingleAttributeScalar(Class clazz, JsonNode node
}
}
- private static Method findSingleAttributeSetter(Class> clazz) {
+ private static Method findSingleSetterOrNullForAnnotation(Class> clazz, Class extends java.lang.annotation.Annotation> annotation) {
List setters = new ArrayList<>();
- ReflectionUtils.doWithMethods(clazz, m -> {
- if (m.isAnnotationPresent(MCAttribute.class) && m.getParameterCount() == 1) {
+ doWithMethods(clazz, m -> {
+ if (m.isAnnotationPresent(annotation) && m.getParameterCount() == 1) {
setters.add(m);
}
});
+ if (setters.isEmpty()) return null;
if (setters.size() != 1) {
throw new ParsingException(
- "@MCElement(singleAttribute=true) requires exactly one @MCAttribute setter, but found " + setters.size() + ".",
+ "Multiple @%s setters found for collapsed element.".formatted(annotation.getSimpleName()),
JsonNodeFactory.instance.nullNode()
);
}
return setters.getFirst();
}
+
}
\ No newline at end of file
diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java
index 18eb98d45c..a7d2036503 100644
--- a/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java
+++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java
@@ -194,9 +194,9 @@ public static String getElementName(Class> type) {
return type.getSimpleName();
}
- public static boolean isSingleAttribute(Class> clazz) {
+ public static boolean isCollapsed(Class> clazz) {
MCElement el = clazz.getAnnotation(MCElement.class);
- return el != null && el.singleAttribute();
+ return el != null && el.collapsed();
}
}
\ No newline at end of file
diff --git a/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingCollapsedTest.java b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingCollapsedTest.java
new file mode 100644
index 0000000000..4930bc6b55
--- /dev/null
+++ b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingCollapsedTest.java
@@ -0,0 +1,211 @@
+package com.predic8.membrane.annot;
+
+import com.predic8.membrane.annot.util.CompilerHelper;
+import com.predic8.membrane.annot.yaml.YamlSchemaValidationException;
+import org.junit.jupiter.api.Test;
+
+import static com.predic8.membrane.annot.SpringConfigurationXSDGeneratingAnnotationProcessorTest.MC_MAIN_DEMO;
+import static com.predic8.membrane.annot.util.CompilerHelper.*;
+import static com.predic8.membrane.annot.util.StructureAssertionUtil.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class YAMLParsingCollapsedTest {
+
+ @Test
+ void collapsedAttributeInline() {
+ var sources = splitSources(MC_MAIN_DEMO + """
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ @MCElement(name="demo", topLevel=true, component=false)
+ public class DemoElement {
+ ChildElement child;
+ public ChildElement getChild() { return child; }
+ @MCChildElement public void setChild(ChildElement child) { this.child = child; }
+ }
+ ---
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ @MCElement(name="child", collapsed=true)
+ public class ChildElement {
+ int value = 0;
+ @MCAttribute public void setValue(int value) { this.value = value; }
+ public int getValue() { return value; }
+ }
+ """);
+
+ var result = CompilerHelper.compile(sources, false);
+ assertCompilerResult(true, result);
+
+ assertStructure(
+ parseYAML(result, """
+ demo:
+ child: 12
+ """),
+ clazz("DemoElement",
+ property("child", clazz("ChildElement",
+ property("value", value(12))))))
+ ;
+ }
+
+ @Test
+ void collapsedAttributeInlineList_noEnvelope() {
+ var sources = splitSources(MC_MAIN_DEMO + """
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ import java.util.List;
+ @MCElement(name="demo", topLevel=true, component=false, noEnvelope=true)
+ public class DemoElement {
+ List children;
+ public List getChildren() { return children; }
+ @MCChildElement public void setChildren(List children) { this.children = children; }
+ }
+ ---
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ @MCElement(name="child", collapsed=true)
+ public class ChildElement {
+ String value = "foo";
+ @MCAttribute public void setValue(String value) { this.value = value; }
+ public String getValue() { return value; }
+ }
+ """);
+
+ var result = CompilerHelper.compile(sources, false);
+ assertCompilerResult(true, result);
+
+ assertStructure(
+ parseYAML(result, """
+ demo:
+ - child: bar
+ - child: baz
+ """),
+ clazz("DemoElement",
+ property("children", list(
+ clazz("ChildElement", property("value", value("bar"))),
+ clazz("ChildElement", property("value", value("baz")))
+ ))))
+ ;
+ }
+
+ @Test
+ void collapsedTextContentInline() {
+ var sources = splitSources(MC_MAIN_DEMO + """
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ @MCElement(name="demo", topLevel=true, component=false)
+ public class DemoElement {
+ ChildElement child;
+ public ChildElement getChild() { return child; }
+ @MCChildElement public void setChild(ChildElement child) { this.child = child; }
+ }
+ ---
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ @MCElement(name="child", collapsed=true, mixed=true)
+ public class ChildElement {
+ String content;
+ public String getContent() { return content; }
+ @MCTextContent public void setContent(String content) { this.content = content; }
+ }
+ """);
+
+ var result = CompilerHelper.compile(sources, false);
+ assertCompilerResult(true, result);
+
+ assertStructure(
+ parseYAML(result, """
+ demo:
+ child: hello
+ """),
+ clazz("DemoElement",
+ property("child", clazz("ChildElement",
+ property("content", value("hello"))))))
+ ;
+ }
+
+ @Test
+ void collapsedTextContentInlineList_noEnvelope() {
+ var sources = splitSources(MC_MAIN_DEMO + """
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ import java.util.List;
+ @MCElement(name="demo", topLevel=true, component=false, noEnvelope=true)
+ public class DemoElement {
+ List children;
+ public List getChildren() { return children; }
+ @MCChildElement public void setChildren(List children) { this.children = children; }
+ }
+ ---
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ @MCElement(name="child", collapsed=true, mixed=true)
+ public class ChildElement {
+ String content;
+ public String getContent() { return content; }
+ @MCTextContent public void setContent(String content) { this.content = content; }
+ }
+ """);
+
+ var result = CompilerHelper.compile(sources, false);
+ assertCompilerResult(true, result);
+
+ assertStructure(
+ parseYAML(result, """
+ demo:
+ - child: a
+ - child: b
+ """),
+ clazz("DemoElement",
+ property("children", list(
+ clazz("ChildElement", property("content", value("a"))),
+ clazz("ChildElement", property("content", value("b")))
+ ))))
+ ;
+ }
+
+ @Test
+ void collapsedRejectsObjectShape_schemaValidation() {
+ var sources = splitSources(MC_MAIN_DEMO + """
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ @MCElement(name="demo", topLevel=true, component=false)
+ public class DemoElement {
+ ChildElement child;
+ @MCChildElement public void setChild(ChildElement child) { this.child = child; }
+ }
+ ---
+ package com.predic8.membrane.demo;
+ import com.predic8.membrane.annot.*;
+ @MCElement(name="child", collapsed=true)
+ public class ChildElement {
+ int value;
+ @MCAttribute public void setValue(int value) { this.value = value; }
+ }
+ """);
+
+ var result = CompilerHelper.compile(sources, false);
+ assertCompilerResult(true, result);
+
+ RuntimeException ex = assertThrows(RuntimeException.class, () ->
+ parseYAML(result, """
+ demo:
+ child:
+ value: 12
+ """)
+ );
+
+ assertFalse(((YamlSchemaValidationException) rootCause(ex)).getErrors().isEmpty());
+ }
+
+ private static Throwable rootCause(Throwable e) {
+ Throwable cur = e;
+ while (true) {
+ if (cur instanceof java.lang.reflect.InvocationTargetException ite) {
+ cur = ite.getTargetException();
+ continue;
+ }
+ if (cur.getCause() == null) return cur;
+ cur = cur.getCause();
+ }
+ }
+}
diff --git a/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingTest.java b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingTest.java
index 524c18aaf3..9e70c77b01 100644
--- a/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingTest.java
+++ b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingTest.java
@@ -15,12 +15,9 @@
package com.predic8.membrane.annot;
import com.predic8.membrane.annot.beanregistry.BeanRegistry;
-import com.predic8.membrane.annot.beanregistry.SpringContextAdapter;
import com.predic8.membrane.annot.util.CompilerHelper;
import com.predic8.membrane.annot.yaml.YamlSchemaValidationException;
-import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
-import org.springframework.context.ConfigurableApplicationContext;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
@@ -661,114 +658,6 @@ public void destroy() throws Exception {
}
- @Test
- void singleAttributeInline() {
- var sources = splitSources(MC_MAIN_DEMO + """
- package com.predic8.membrane.demo;
- import com.predic8.membrane.annot.*;
- import java.util.List;
- @MCElement(name="demo", topLevel=true, component=false)
- public class DemoElement {
- ChildElement child;
-
- public ChildElement getChild() { return child; }
- @MCChildElement
- public void setChild(ChildElement child) { this.child = child; }
- }
- ---
- package com.predic8.membrane.demo;
- import com.predic8.membrane.annot.*;
- import jakarta.annotation.*;
- import static org.junit.jupiter.api.Assertions.assertEquals;
- @MCElement(name="child", singleAttribute=true)
- public class ChildElement {
-
- int value = 0;
-
- @MCAttribute
- public void setValue(int value) {
- this.value = value;
- }
-
- public int getValue() {
- return value;
- }
-
- }
- """);
-
- var result = CompilerHelper.compile(sources, false);
- assertCompilerResult(true, result);
-
- assertStructure(
- parseYAML(result, """
- demo:
- child: 12
- """),
- clazz("DemoElement",
- property("child", clazz("ChildElement",
- property("value", value(12))))));
- }
-
- @Test
- void singleAttributeInlineList() {
- var sources = splitSources(MC_MAIN_DEMO + """
- package com.predic8.membrane.demo;
- import com.predic8.membrane.annot.*;
- import java.util.List;
- @MCElement(name="demo", topLevel=true, component=false, noEnvelope=true)
- public class DemoElement {
- List children;
-
- public List getChildren() {
- return children;
- }
-
- @MCChildElement
- public void setChildren(List children) {
- this.children = children;
- }
- }
- ---
- package com.predic8.membrane.demo;
- import com.predic8.membrane.annot.*;
- import jakarta.annotation.*;
- import static org.junit.jupiter.api.Assertions.assertEquals;
- @MCElement(name="child", singleAttribute=true)
- public class ChildElement {
-
- String value = "foo";
-
- @MCAttribute
- public void setValue(String value) {
- this.value = value;
- }
-
- public String getValue() {
- return value;
- }
-
- }
- """);
-
- var result = CompilerHelper.compile(sources, false);
- assertCompilerResult(true, result);
-
- assertStructure(
- parseYAML(result, """
- demo:
- - child: bar
- - child: baz
- """),
- clazz("DemoElement",
- property("children", list(
- clazz("ChildElement",
- property("value", value("bar"))),
- clazz("ChildElement",
- property("value", value("baz")))))));
- }
-
-
private Throwable getCause(Throwable e) {
if (e.getCause() != null)
return getCause(e.getCause());
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index c6d94aaee8..b899537401 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -23,6 +23,7 @@
- refactor JdbcUserDataProvider
- Refine YAML for balancer: clustersFromSpring
- wsdlRewriter YAML is not working
+- use @MCElement(collapsed=true) for suitable classes
# 7.0.4