Skip to content

Commit 78ed41d

Browse files
Merge branch 'master' into migration-guide-for-7
2 parents fcc5bfc + d338822 commit 78ed41d

16 files changed

Lines changed: 558 additions & 115 deletions

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

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -221,20 +221,9 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
221221
if (ii.getAnnotation().component())
222222
main.getComponents().put(ii.getAnnotation().name(), ii);
223223

224-
if (ii.getAnnotation().noEnvelope()) {
225-
if (ii.getAnnotation().mixed())
226-
throw new ProcessingException("@MCElement(..., noEnvelope=true, mixed=true) is invalid.", ii.getElement());
227-
if (ii.getChildElementSpecs().size() != 1)
228-
throw new ProcessingException("@MCElement(noEnvelope=true) requires exactly one @MCChildElement.", ii.getElement());
229-
if (!ii.getChildElementSpecs().getFirst().isList())
230-
throw new ProcessingException("@MCElement(noEnvelope=true) requires its @MCChildElement() to be a List or Collection.", ii.getElement());
231-
if (!ii.getAis().isEmpty())
232-
throw new ProcessingException("@MCElement(noEnvelope=true) requires @MCAttribute to be not present.", ii.getElement());
233-
if (ii.getOai() != null)
234-
throw new ProcessingException("@MCElement(noEnvelope=true) requires @MCOtherAttributes to be not present.", ii.getElement());
235-
if (ii.getTci() != null)
236-
throw new ProcessingException("@MCElement(noEnvelope=true) requires @MCTextContent to be not present.", ii.getElement());
237-
}
224+
validateNoEnvelope(ii);
225+
validateCollapsed(ii);
226+
238227
if (ii.getTci() != null && !ii.getAnnotation().mixed())
239228
throw new ProcessingException("@MCTextContent requires @MCElement(..., mixed=true) on the class.", ii.getElement());
240229
if (ii.getTci() == null && ii.getAnnotation().mixed())
@@ -300,6 +289,42 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
300289
}
301290
}
302291

292+
private static void validateNoEnvelope(ElementInfo ii) {
293+
if (ii.getAnnotation().noEnvelope()) {
294+
if (ii.getAnnotation().mixed())
295+
throw new ProcessingException("@MCElement(..., noEnvelope=true, mixed=true) is invalid.", ii.getElement());
296+
if (ii.getChildElementSpecs().size() != 1)
297+
throw new ProcessingException("@MCElement(noEnvelope=true) requires exactly one @MCChildElement.", ii.getElement());
298+
if (!ii.getChildElementSpecs().getFirst().isList())
299+
throw new ProcessingException("@MCElement(noEnvelope=true) requires its @MCChildElement() to be a List or Collection.", ii.getElement());
300+
if (!ii.getAis().isEmpty())
301+
throw new ProcessingException("@MCElement(noEnvelope=true) requires @MCAttribute to be not present.", ii.getElement());
302+
if (ii.getOai() != null)
303+
throw new ProcessingException("@MCElement(noEnvelope=true) requires @MCOtherAttributes to be not present.", ii.getElement());
304+
if (ii.getTci() != null)
305+
throw new ProcessingException("@MCElement(noEnvelope=true) requires @MCTextContent to be not present.", ii.getElement());
306+
}
307+
}
308+
309+
private static void validateCollapsed(ElementInfo ii) {
310+
// A collapsed class has exactly one @MCAttribute OR exactly one @MCTextContent and no @MCElement or @MCAttribute.
311+
if (ii.getAnnotation().collapsed()) {
312+
int attrCount = ii.getAis().size();
313+
if (hasAttributeAndTextContent(attrCount, ii))
314+
throw new ProcessingException("@MCElement(collapsed=true) requires exactly one @MCAttribute OR exactly one @MCTextContent.", ii.getElement());
315+
if (attrCount != 0 && attrCount != 1)
316+
throw new ProcessingException("@MCElement(collapsed=true) allows only one @MCAttribute setter.", ii.getElement());
317+
if (!ii.getChildElementSpecs().isEmpty())
318+
throw new ProcessingException("@MCElement(collapsed=true) must not declare @MCChildElement.", ii.getElement());
319+
if (ii.getOai() != null)
320+
throw new ProcessingException("@MCElement(collapsed=true) must not declare @MCOtherAttributes.", ii.getElement());
321+
}
322+
}
323+
324+
private static boolean hasAttributeAndTextContent(int attrCount, ElementInfo ii) {
325+
return (attrCount > 0 ? 1 : 0) + (ii.getTci() != null ? 1 : 0) != 1;
326+
}
327+
303328
private boolean isComponent(TypeElement type) {
304329
MCElement mcElement = type.getAnnotation(MCElement.class);
305330
return (mcElement != null) && mcElement.component();

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

Lines changed: 39 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -19,42 +19,32 @@
1919
import com.fasterxml.jackson.databind.ObjectMapper;
2020
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
2121
import com.fasterxml.jackson.databind.node.ObjectNode;
22-
import com.networknt.schema.Error;
23-
import com.networknt.schema.Schema;
24-
import com.networknt.schema.SchemaLocation;
25-
import com.networknt.schema.SchemaRegistry;
2622
import com.predic8.membrane.annot.Grammar;
2723
import com.predic8.membrane.annot.MCAttribute;
28-
import com.predic8.membrane.annot.MCElement;
2924
import com.predic8.membrane.annot.MCTextContent;
3025
import com.predic8.membrane.annot.beanregistry.BeanDefinition;
3126
import com.predic8.membrane.annot.beanregistry.BeanLifecycleManager;
3227
import com.predic8.membrane.annot.beanregistry.BeanRegistry;
33-
import com.predic8.membrane.annot.beanregistry.BeanRegistryAware;
34-
import jakarta.annotation.PostConstruct;
35-
import jakarta.annotation.PreDestroy;
3628
import org.jetbrains.annotations.NotNull;
3729
import org.slf4j.Logger;
3830
import org.slf4j.LoggerFactory;
39-
import org.springframework.util.ReflectionUtils;
4031

4132
import java.io.IOException;
4233
import java.io.InputStream;
43-
import java.lang.annotation.Annotation;
4434
import java.lang.reflect.InvocationTargetException;
4535
import java.lang.reflect.Method;
4636
import java.util.ArrayList;
4737
import java.util.Iterator;
4838
import java.util.List;
39+
import java.util.Objects;
4940

50-
import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12;
5141
import static com.predic8.membrane.annot.yaml.McYamlIntrospector.*;
5242
import static com.predic8.membrane.annot.yaml.MethodSetter.getMethodSetter;
5343
import static com.predic8.membrane.annot.yaml.NodeValidationUtils.*;
44+
import static com.predic8.membrane.annot.yaml.YamlParsingUtils.*;
5445
import static java.nio.charset.StandardCharsets.UTF_8;
5546
import static java.util.List.of;
5647
import static java.util.UUID.randomUUID;
57-
import static org.springframework.util.ReflectionUtils.doWithMethods;
5848

5949
public class GenericYamlParser {
6050
private static final Logger log = LoggerFactory.getLogger(GenericYamlParser.class);
@@ -145,16 +135,6 @@ private static String getBeanType(JsonNode jsonNode) {
145135
return jsonNode.fieldNames().next();
146136
}
147137

148-
private static void validate(Grammar grammar, JsonNode input) throws YamlSchemaValidationException {
149-
Schema schema = SchemaRegistry.withDefaultDialect(DRAFT_2020_12, builder -> {}).getSchema(SchemaLocation.of(grammar.getSchemaLocation()));
150-
schema.initializeValidators();
151-
List<Error> errors = schema.validate(input);
152-
if (!errors.isEmpty()) {
153-
throw new YamlSchemaValidationException("Invalid YAML.", errors);
154-
}
155-
}
156-
157-
158138
/**
159139
* Parse a top-level Membrane resource of the given {@code kind}.
160140
* <p>Ensures the node contains exactly one key (the kind), resolves the Java class via the
@@ -185,49 +165,27 @@ public static Class<?> decideClazz(String kind, Grammar grammar, JsonNode node)
185165
public static <T> T createAndPopulateNode(ParsingContext<?> ctx, Class<T> clazz, JsonNode node) throws ParsingException {
186166
try {
187167
T configObj = clazz.getConstructor().newInstance();
168+
169+
// when this is a list, we are on a @MCElement(..., noEnvelope=true)
188170
if (node.isArray()) {
189-
// when this is a list, we are on a @MCElement(..., noEnvelope=true)
190-
Method method = getSingleChildSetter(clazz);
191-
method.invoke(configObj, parseListExcludingStartEvent(ctx, node));
192-
return configObj;
171+
return handlePostConstructAndPreDestroy(ctx, handleNoEnvelopeList(ctx, clazz, node, configObj));
193172
}
194173

195174
// scalar inline form for @MCElement(collapsed=true)
196175
if (isCollapsed(clazz)) {
197-
if (node.isNull()) {
198-
throw new ParsingException("Collapsed element must not be null.", node);
199-
}
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);
176+
return handleCollapsed(ctx, clazz, node, configObj);
206177
}
207178
ensureMappingStart(node);
208-
if (isNoEnvelope(clazz))
209-
throw new RuntimeException("Class " + clazz.getName() + " is annotated with @MCElement(noEnvelope=true), but the YAML/JSON structure does not contain a list.");
179+
if (isNoEnvelope(clazz)) throw new ParsingException("Class %s is annotated with @MCElement(noEnvelope=true), but the YAML/JSON structure does not contain a list.".formatted(clazz.getName()), node);
210180

211181
JsonNode refNode = node.get("$ref");
212182
if (refNode != null) {
213183
applyObjectLevelRef(ctx, clazz, node, refNode, configObj);
214184
}
215185

216186
List<Method> required = findRequiredSetters(clazz);
187+
populateObjectFields(ctx, clazz, node, required, configObj);
217188

218-
for (Iterator<String> it = node.fieldNames(); it.hasNext(); ) {
219-
String key = it.next();
220-
if ("$ref".equals(key))
221-
continue;
222-
223-
try {
224-
MethodSetter methodSetter = getMethodSetter(ctx, clazz, key);
225-
required.remove(methodSetter.getSetter());
226-
methodSetter.setSetter(configObj, ctx, node, key);
227-
} catch (Throwable cause) {
228-
throw new ParsingException(cause, node.get(key));
229-
}
230-
}
231189
if (!required.isEmpty())
232190
throw new ParsingException("Missing required fields: " + required.stream().map(McYamlIntrospector::getSetterName).toList(), node);
233191
return handlePostConstructAndPreDestroy(ctx, configObj);
@@ -244,6 +202,34 @@ public static <T> T createAndPopulateNode(ParsingContext<?> ctx, Class<T> clazz,
244202
}
245203
}
246204

205+
private static <T> void populateObjectFields(ParsingContext<?> ctx, Class<T> clazz, JsonNode node, List<Method> required, T configObj) {
206+
for (Iterator<String> it = node.fieldNames(); it.hasNext(); ) {
207+
String key = it.next();
208+
if ("$ref".equals(key))
209+
continue;
210+
211+
try {
212+
MethodSetter methodSetter = getMethodSetter(ctx, clazz, key);
213+
required.remove(methodSetter.getSetter());
214+
methodSetter.setSetter(configObj, ctx, node, key);
215+
} catch (Throwable cause) {
216+
throw new ParsingException(cause, node.get(key));
217+
}
218+
}
219+
}
220+
221+
private static <T> @NotNull T handleCollapsed(ParsingContext<?> ctx, Class<T> clazz, JsonNode node, T configObj) {
222+
if (node.isNull()) throw new ParsingException("Collapsed element must not be null.", node);
223+
if (node.isArray() || node.isObject()) throw new ParsingException("Element is collapsed; expected an inline scalar value, not an %s.".formatted((node.isArray() ? "array" : "object")), node);
224+
applyCollapsedScalar(clazz, node, configObj);
225+
return handlePostConstructAndPreDestroy(ctx, configObj);
226+
}
227+
228+
private static <T> T handleNoEnvelopeList(ParsingContext<?> ctx, Class<T> clazz, JsonNode node, T configObj) throws IllegalAccessException, InvocationTargetException {
229+
getSingleChildSetter(clazz).invoke(configObj, parseListExcludingStartEvent(ctx, node));
230+
return configObj;
231+
}
232+
247233
private static List<BeanDefinition> extractComponentBeanDefinitions(JsonNode componentsNode) {
248234
if (componentsNode == null || componentsNode.isNull())
249235
return of();
@@ -341,54 +327,23 @@ private static Object parseMapToObj(ParsingContext<?> ctx, JsonNode node, String
341327
return createAndPopulateNode(ctx.updateContext(key), ctx.resolveClass(key), node);
342328
}
343329

344-
/**
345-
* Calls the @PostConstruct method on the bean and returns it. If there are @PreDestroy methods, they will be
346-
* registered within the registry.
347-
*/
348-
private static <T> T handlePostConstructAndPreDestroy(ParsingContext<?> ctx, T bean) {
349-
if (bean instanceof BeanRegistryAware beanRegistryAware) {
350-
beanRegistryAware.setRegistry(ctx.registry());
351-
}
352-
doWithMethods(bean.getClass(), method -> {
353-
if (method.isAnnotationPresent(PostConstruct.class)) {
354-
try {
355-
method.setAccessible(true);
356-
method.invoke(bean);
357-
} catch (InvocationTargetException e) {
358-
throw new RuntimeException(e.getTargetException());
359-
} catch (IllegalAccessException | IllegalArgumentException e) {
360-
throw new RuntimeException(e);
361-
}
362-
}
363-
if (method.isAnnotationPresent(PreDestroy.class)) {
364-
method.setAccessible(true);
365-
ctx.registry().addPreDestroyCallback(bean, method);
366-
}
367-
});
368-
return bean;
369-
}
370-
371330
private static <T> void applyCollapsedScalar(Class<T> clazz, JsonNode node, T target) {
372331
if (node == null || node.isNull()) {
373332
throw new ParsingException("Collapsed element must not be null.", node);
374333
}
375334

335+
// Collapsed classes can only have one matching setter (ensured by SpringConfigurationXSDGeneratingAnnotationProcessor)
376336
Method attributeSetter = findSingleSetterOrNullForAnnotation(clazz, MCAttribute.class);
377337
Method textSetter = findSingleSetterOrNullForAnnotation(clazz, MCTextContent.class);
378338

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-
}
383-
384339
Method setter = (attributeSetter != null) ? attributeSetter : textSetter;
385-
Class<?> paramType = setter.getParameterTypes()[0];
340+
Class<?> paramType = Objects.requireNonNull(setter).getParameterTypes()[0];
386341

387342
Object value;
388343
try {
389344
value = SCALAR_MAPPER.convertValue(node, paramType);
390345
} catch (IllegalArgumentException e) {
391-
throw new ParsingException("Cannot convert inline value to " + paramType.getSimpleName() + ".", node);
346+
throw new ParsingException("Cannot convert inline value to %s.".formatted(paramType.getSimpleName()), node);
392347
}
393348

394349
try {
@@ -401,22 +356,6 @@ private static <T> void applyCollapsedScalar(Class<T> clazz, JsonNode node, T ta
401356
}
402357
}
403358

404-
private static Method findSingleSetterOrNullForAnnotation(Class<?> clazz, Class<? extends java.lang.annotation.Annotation> annotation) {
405-
List<Method> setters = new ArrayList<>();
406-
doWithMethods(clazz, m -> {
407-
if (m.isAnnotationPresent(annotation) && m.getParameterCount() == 1) {
408-
setters.add(m);
409-
}
410-
});
411359

412-
if (setters.isEmpty()) return null;
413-
if (setters.size() != 1) {
414-
throw new ParsingException(
415-
"Multiple @%s setters found for collapsed element.".formatted(annotation.getSimpleName()),
416-
JsonNodeFactory.instance.nullNode()
417-
);
418-
}
419-
return setters.getFirst();
420-
}
421360

422361
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.predic8.membrane.annot.yaml;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.networknt.schema.Error;
5+
import com.networknt.schema.Schema;
6+
import com.networknt.schema.SchemaLocation;
7+
import com.networknt.schema.SchemaRegistry;
8+
import com.predic8.membrane.annot.Grammar;
9+
import com.predic8.membrane.annot.beanregistry.BeanRegistryAware;
10+
import jakarta.annotation.PostConstruct;
11+
import jakarta.annotation.PreDestroy;
12+
import org.jetbrains.annotations.NotNull;
13+
14+
import java.lang.reflect.InvocationTargetException;
15+
import java.lang.reflect.Method;
16+
import java.util.ArrayList;
17+
import java.util.List;
18+
import java.util.concurrent.ConcurrentHashMap;
19+
20+
import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12;
21+
import static org.springframework.util.ReflectionUtils.doWithMethods;
22+
23+
public final class YamlParsingUtils {
24+
25+
private YamlParsingUtils() {
26+
}
27+
28+
private record SchemaCacheKey(String schemaLocation, ClassLoader classLoader) {
29+
}
30+
31+
private static final ConcurrentHashMap<SchemaCacheKey, Schema> SCHEMA_CACHE = new ConcurrentHashMap<>();
32+
33+
static void validate(Grammar grammar, JsonNode input) throws YamlSchemaValidationException {
34+
List<Error> errors = loadSchema(grammar).validate(input);
35+
if (!errors.isEmpty())
36+
throw new YamlSchemaValidationException("Invalid YAML.", errors);
37+
}
38+
39+
private static @NotNull Schema loadSchema(Grammar grammar) {
40+
// Schema cache: prevents duplicate loads for multi-doc specs (---).
41+
return SCHEMA_CACHE.computeIfAbsent(new SchemaCacheKey(grammar.getSchemaLocation(), Thread.currentThread().getContextClassLoader()), k -> {
42+
Schema s = SchemaRegistry.withDefaultDialect(DRAFT_2020_12, b -> {
43+
})
44+
.getSchema(SchemaLocation.of(k.schemaLocation()));
45+
s.initializeValidators();
46+
return s;
47+
});
48+
}
49+
50+
/**
51+
* Calls the @PostConstruct method on the bean and returns it. If there are @PreDestroy methods, they will be
52+
* registered within the registry.
53+
*/
54+
static <T> T handlePostConstructAndPreDestroy(ParsingContext<?> ctx, T bean) {
55+
if (bean instanceof BeanRegistryAware beanRegistryAware) {
56+
beanRegistryAware.setRegistry(ctx.registry());
57+
}
58+
doWithMethods(bean.getClass(), method -> {
59+
if (method.isAnnotationPresent(PostConstruct.class)) invokeNoArg(bean, method);
60+
if (method.isAnnotationPresent(PreDestroy.class)) {
61+
method.setAccessible(true);
62+
ctx.registry().addPreDestroyCallback(bean, method);
63+
}
64+
});
65+
return bean;
66+
}
67+
68+
private static void invokeNoArg(Object bean, Method m) {
69+
try {
70+
m.setAccessible(true);
71+
m.invoke(bean);
72+
} catch (InvocationTargetException e) {
73+
throw new RuntimeException(e.getTargetException());
74+
} catch (Throwable t) {
75+
throw new RuntimeException(t);
76+
}
77+
}
78+
79+
/**
80+
* Searches for a single setter method in the given class that is annotated with the specified annotation.
81+
*/
82+
static Method findSingleSetterOrNullForAnnotation(Class<?> clazz, Class<? extends java.lang.annotation.Annotation> annotation) {
83+
List<Method> setters = new ArrayList<>();
84+
doWithMethods(clazz, m -> {
85+
if (m.isAnnotationPresent(annotation) && m.getParameterCount() == 1) {
86+
setters.add(m);
87+
}
88+
});
89+
90+
if (setters.isEmpty()) return null;
91+
return setters.getFirst();
92+
}
93+
94+
}

0 commit comments

Comments
 (0)