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 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