Skip to content

Commit 554c8e4

Browse files
Rename singleAttribute to collapsed and make it work for both Attributes and TextContent (#2585)
* Rename `singleAttribute` to `collapsed` in MCElement and update related usages. * Refactor handling of `collapsed` elements and update inline parsing logic. * Extract collapsed element tests to `YAMLParsingCollapsedTest` for better organization. * coderabbitai suggestions * comment improvement * .
1 parent 0204f9a commit 554c8e4

7 files changed

Lines changed: 298 additions & 163 deletions

File tree

annot/src/main/java/com/predic8/membrane/annot/MCElement.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@
6969
* Whether the element has only one attribute.
7070
* Enables inline yaml object configuration
7171
* e.g.
72+
* <pre><code>
7273
* allow: foo
74+
* </code></pre>
75+
* instead of
76+
* <pre><code>
77+
* allow:
78+
* value: foo
79+
* </code></pre>
7380
*/
74-
boolean singleAttribute() default false;
81+
boolean collapsed() default false;
7582
}

annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ private void addParserDefinitions(Model m, MainInfo main) {
110110
}
111111
}
112112

113-
private SchemaObject createParser(Model m, MainInfo main, ElementInfo elementInfo) {
113+
private AbstractSchema<?> createParser(Model m, MainInfo main, ElementInfo elementInfo) {
114114
String parserName = elementInfo.getXSDTypeName(m);
115115

116116
if (isComponentsMap(elementInfo)) {
@@ -133,6 +133,14 @@ private SchemaObject createParser(Model m, MainInfo main, ElementInfo elementInf
133133
return ref(parserName).ref("#/$defs/%sParser".formatted(childName));
134134
}
135135

136+
// enforce the inline form for collapsed elements
137+
if (elementInfo.getAnnotation().collapsed()) {
138+
if (elementInfo.getAnnotation().noEnvelope()) {
139+
throw new ProcessingException("@MCElement(collapsed=true) is not compatible with noEnvelope=true.", elementInfo.getElement());
140+
}
141+
return createCollapsedInlineParser(elementInfo, parserName);
142+
}
143+
136144
SchemaObject parser = getParserSchemaObject(elementInfo, parserName);
137145

138146
collectProperties(m, main, elementInfo, parser);
@@ -144,50 +152,53 @@ private SchemaObject createParser(Model m, MainInfo main, ElementInfo elementInf
144152
.required(false));
145153
}
146154

147-
// allow scalar inline form for single-attribute elements
148-
if (elementInfo.getAnnotation().singleAttribute()) {
149-
return anyOf(List.of(
150-
parser,
151-
createSingleAttributeInlineVariant(elementInfo)
152-
)).name(parserName);
153-
}
154-
155155
return parser;
156156
}
157157

158-
private AbstractSchema<?> createSingleAttributeInlineVariant(ElementInfo ei) {
159-
// Only count attributes that are actually part of the schema.
160-
var attrs = ei.getAis().stream()
161-
.filter(ai -> !ai.excludedFromJsonSchema())
162-
.toList();
158+
private AbstractSchema<?> createCollapsedInlineParser(ElementInfo ei, String parserName) {
159+
var attrs = ei.getAis().stream().toList();
160+
161+
boolean hasText = ei.getTci() != null;
162+
boolean hasChildren = !ei.getChildElementSpecs().isEmpty();
163163

164-
if (attrs.size() != 1) {
165-
throw new ProcessingException(
166-
"@MCElement(singleAttribute=true) requires exactly one @MCAttribute.",
167-
ei.getElement()
168-
);
164+
if (hasChildren) {
165+
throw new ProcessingException("@MCElement(collapsed=true) must not declare child elements.", ei.getElement());
169166
}
170-
if (ei.getTci() != null || !ei.getChildElementSpecs().isEmpty()) {
171-
throw new ProcessingException(
172-
"@MCElement(singleAttribute=true) must not declare text content or child elements.",
173-
ei.getElement()
174-
);
167+
if (hasText && !attrs.isEmpty()) {
168+
throw new ProcessingException("@MCElement(collapsed=true) must not mix @MCTextContent with @MCAttribute.", ei.getElement());
175169
}
176170

177-
AttributeInfo ai = attrs.getFirst();
178-
String type = ai.getSchemaType(processingEnv.getTypeUtils());
171+
// collapsed via single @MCTextContent -> scalar string
172+
if (hasText) {
173+
return SchemaFactory.from("string")
174+
.name(parserName)
175+
.type("string")
176+
.description(getDescriptionContent(ei));
177+
}
179178

180-
AbstractSchema<?> s = SchemaFactory.from(type)
181-
.type(type)
182-
.description(getDescriptionContent(ai));
179+
// collapsed via a single @MCAttribute-> scalar of that attribute type
180+
if (attrs.size() == 1) {
181+
AttributeInfo ai = attrs.getFirst();
182+
String type = ai.getSchemaType(processingEnv.getTypeUtils());
183183

184-
if (ai.isEnum(processingEnv.getTypeUtils()) && !"boolean".equals(type)) {
185-
s.enumValues(ai.enumsAsLowerCaseList(processingEnv.getTypeUtils()));
184+
AbstractSchema<?> s = SchemaFactory.from(type)
185+
.name(parserName)
186+
.type(type)
187+
.description(getDescriptionContent(ai).isEmpty() ? getDescriptionContent(ei) : getDescriptionContent(ai));
188+
189+
if (ai.isEnum(processingEnv.getTypeUtils()) && !"boolean".equals(type)) {
190+
s.enumValues(ai.enumsAsLowerCaseList(processingEnv.getTypeUtils()));
191+
}
192+
return s;
186193
}
187194

188-
return s;
195+
throw new ProcessingException(
196+
"@MCElement(collapsed=true) requires exactly one @MCAttribute or exactly one @MCTextContent.",
197+
ei.getElement()
198+
);
189199
}
190200

201+
191202
private SchemaObject getParserSchemaObject(ElementInfo elementInfo, String parserName) {
192203
return object(parserName)
193204
.additionalProperties( elementInfo.isString())

annot/src/main/java/com/predic8/membrane/annot/yaml/GenericYamlParser.java

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import com.networknt.schema.SchemaRegistry;
2626
import com.predic8.membrane.annot.Grammar;
2727
import com.predic8.membrane.annot.MCAttribute;
28+
import com.predic8.membrane.annot.MCElement;
29+
import com.predic8.membrane.annot.MCTextContent;
2830
import com.predic8.membrane.annot.beanregistry.BeanDefinition;
2931
import com.predic8.membrane.annot.beanregistry.BeanLifecycleManager;
3032
import com.predic8.membrane.annot.beanregistry.BeanRegistry;
@@ -38,6 +40,7 @@
3840

3941
import java.io.IOException;
4042
import java.io.InputStream;
43+
import java.lang.annotation.Annotation;
4144
import java.lang.reflect.InvocationTargetException;
4245
import java.lang.reflect.Method;
4346
import java.util.ArrayList;
@@ -51,6 +54,7 @@
5154
import static java.nio.charset.StandardCharsets.UTF_8;
5255
import static java.util.List.of;
5356
import static java.util.UUID.randomUUID;
57+
import static org.springframework.util.ReflectionUtils.doWithMethods;
5458

5559
public class GenericYamlParser {
5660
private static final Logger log = LoggerFactory.getLogger(GenericYamlParser.class);
@@ -188,16 +192,18 @@ public static <T> T createAndPopulateNode(ParsingContext<?> ctx, Class<T> clazz,
188192
return configObj;
189193
}
190194

191-
// scalar inline form for @MCElement(singleAttribute=true)
192-
if (!node.isObject()) {
193-
if (isSingleAttribute(clazz)) {
194-
applySingleAttributeScalar(clazz, node, configObj);
195-
return handlePostConstructAndPreDestroy(ctx, configObj);
195+
// scalar inline form for @MCElement(collapsed=true)
196+
if (isCollapsed(clazz)) {
197+
if (node.isNull()) {
198+
throw new ParsingException("Collapsed element must not be null.", node);
196199
}
197-
// anything non-object is invalid here
198-
ensureMappingStart(node);
200+
if (node.isArray() || node.isObject()) {
201+
throw new ParsingException("Element is collapsed; expected an inline scalar value, not " +
202+
(node.isArray() ? "an array" : "an object") + ".", node);
203+
}
204+
applyCollapsedScalar(clazz, node, configObj);
205+
return handlePostConstructAndPreDestroy(ctx, configObj);
199206
}
200-
201207
ensureMappingStart(node);
202208
if (isNoEnvelope(clazz))
203209
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> T handlePostConstructAndPreDestroy(ParsingContext<?> ctx, T b
343349
if (bean instanceof BeanRegistryAware beanRegistryAware) {
344350
beanRegistryAware.setRegistry(ctx.registry());
345351
}
346-
ReflectionUtils.doWithMethods(bean.getClass(), method -> {
352+
doWithMethods(bean.getClass(), method -> {
347353
if (method.isAnnotationPresent(PostConstruct.class)) {
348354
try {
349355
method.setAccessible(true);
@@ -362,19 +368,27 @@ private static <T> T handlePostConstructAndPreDestroy(ParsingContext<?> ctx, T b
362368
return bean;
363369
}
364370

365-
private static <T> void applySingleAttributeScalar(Class<T> clazz, JsonNode node, T target) {
371+
private static <T> void applyCollapsedScalar(Class<T> clazz, JsonNode node, T target) {
366372
if (node == null || node.isNull()) {
367-
throw new ParsingException("singleAttribute element must not be null.", node);
373+
throw new ParsingException("Collapsed element must not be null.", node);
368374
}
369375

370-
Method setter = findSingleAttributeSetter(clazz);
376+
Method attributeSetter = findSingleSetterOrNullForAnnotation(clazz, MCAttribute.class);
377+
Method textSetter = findSingleSetterOrNullForAnnotation(clazz, MCTextContent.class);
378+
379+
if ((attributeSetter == null) == (textSetter == null)) {
380+
// both null or both non-null -> invalid
381+
throw new ParsingException("@MCElement(collapsed=true) requires exactly one @MCAttribute setter OR exactly one @MCTextContent setter.", node);
382+
}
371383

384+
Method setter = (attributeSetter != null) ? attributeSetter : textSetter;
372385
Class<?> paramType = setter.getParameterTypes()[0];
386+
373387
Object value;
374388
try {
375389
value = SCALAR_MAPPER.convertValue(node, paramType);
376390
} catch (IllegalArgumentException e) {
377-
throw new ParsingException("Cannot convert scalar value to " + paramType.getSimpleName() + ".", node);
391+
throw new ParsingException("Cannot convert inline value to " + paramType.getSimpleName() + ".", node);
378392
}
379393

380394
try {
@@ -387,20 +401,22 @@ private static <T> void applySingleAttributeScalar(Class<T> clazz, JsonNode node
387401
}
388402
}
389403

390-
private static Method findSingleAttributeSetter(Class<?> clazz) {
404+
private static Method findSingleSetterOrNullForAnnotation(Class<?> clazz, Class<? extends java.lang.annotation.Annotation> annotation) {
391405
List<Method> setters = new ArrayList<>();
392-
ReflectionUtils.doWithMethods(clazz, m -> {
393-
if (m.isAnnotationPresent(MCAttribute.class) && m.getParameterCount() == 1) {
406+
doWithMethods(clazz, m -> {
407+
if (m.isAnnotationPresent(annotation) && m.getParameterCount() == 1) {
394408
setters.add(m);
395409
}
396410
});
397411

412+
if (setters.isEmpty()) return null;
398413
if (setters.size() != 1) {
399414
throw new ParsingException(
400-
"@MCElement(singleAttribute=true) requires exactly one @MCAttribute setter, but found " + setters.size() + ".",
415+
"Multiple @%s setters found for collapsed element.".formatted(annotation.getSimpleName()),
401416
JsonNodeFactory.instance.nullNode()
402417
);
403418
}
404419
return setters.getFirst();
405420
}
421+
406422
}

annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,9 @@ public static String getElementName(Class<?> type) {
194194
return type.getSimpleName();
195195
}
196196

197-
public static boolean isSingleAttribute(Class<?> clazz) {
197+
public static boolean isCollapsed(Class<?> clazz) {
198198
MCElement el = clazz.getAnnotation(MCElement.class);
199-
return el != null && el.singleAttribute();
199+
return el != null && el.collapsed();
200200
}
201201

202202
}

0 commit comments

Comments
 (0)