Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@

/**
* TODOs:
* - A required property with a base type needs all the subtypes to be present in the schema. See CacheParser
* - ports are strings
* - Choose/cases/case has one nesting to much
* - apiKey/extractors/expressionExtractor/expression => too much?
Expand Down Expand Up @@ -197,6 +196,8 @@ private AbstractSchema<?> createRegularParser(Model model, MainInfo main, Elemen
.required(false));
}

addRequiredChildren(main, elementInfo, parserSchema);

return parserSchema;
}

Expand Down Expand Up @@ -503,9 +504,7 @@ private void addFlowParserRef(AbstractSchema<?> parentSchema, String propertyNam
}

private void addChildsAsProperties(Model model, MainInfo main, ChildElementInfo childSpec, SchemaObject parentObjectSchema, boolean componentsContext, boolean listItemContext) {
var childElementInfos = requireChildElementDeclarationInfo(main, childSpec).getElementInfo().stream()
.filter(candidateElementInfo -> !candidateElementInfo.getAnnotation().topLevel())
.toList();
var childElementInfos = getChildElementInfos(main, childSpec);

// Generic list-item reference support:
// If this list can contain at least one @MCElement(component=true) type,
Expand All @@ -515,10 +514,46 @@ private void addChildsAsProperties(Model model, MainInfo main, ChildElementInfo
}

for (ElementInfo childElementInfo : childElementInfos) {
parentObjectSchema.property(getRef(model, childElementInfo))
.description(getDescriptionContent(childElementInfo))
.required(childSpec.isRequired());
parentObjectSchema.property(getRef(model, childElementInfo)
.description(getDescriptionContent(childElementInfo)));
}
}

private static List<ElementInfo> getChildElementInfos(MainInfo main, ChildElementInfo childSpec) {
return requireChildElementDeclarationInfo(main, childSpec).getElementInfo().stream()
.filter(candidateElementInfo -> !candidateElementInfo.getAnnotation().topLevel())
.toList();
}

// The schema cannot resolve a "$ref" to see which child it fills, so a "$ref" satisfies every required child.
// The parser checks the rest at startup. Not as if/then: IntelliJ's completion then offers the required keys
// in nested objects, too.
private void addRequiredChildren(MainInfo main, ElementInfo elementInfo, SchemaObject parserSchema) {
final var refAllowed = parserSchema.hasProperty($_REF);
final var groups = new ArrayList<AbstractSchema<?>>();
for (ChildElementInfo childSpec : getJsonVisibleChildElementSpecs(elementInfo)) {
if (childSpec.isList() || !childSpec.isRequired())
continue;

final var keys = getChildElementInfos(main, childSpec).stream()
.map(childElementInfo -> childElementInfo.getAnnotation().name())
.toList();
if (keys.isEmpty())
continue;

if (keys.size() == 1 && !refAllowed) {
parserSchema.getProperty(keys.getFirst()).required(true);
continue;
}

final var alternatives = new ArrayList<AbstractSchema<?>>();
keys.forEach(key -> alternatives.add(requiredKey(key)));
if (refAllowed)
alternatives.add(requiredKey($_REF));
groups.add(anyOf(alternatives));
}
if (!groups.isEmpty())
parserSchema.allOf(groups);
}

private static SchemaRef getRef(Model model, ElementInfo elementInfo) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* Copyright 2026 predic8 GmbH, www.predic8.com

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. */

package com.predic8.membrane.annot.generator.kubernetes.model;

import com.fasterxml.jackson.databind.node.*;

/**
* Renders only <code>{"required": [key]}</code>, for use inside <code>anyOf</code>/<code>allOf</code>.
*/
public class RequiredKey extends AbstractSchema<RequiredKey> {

private final String key;

RequiredKey(String key) {
this.key = key;
}

@Override
public ObjectNode json(ObjectNode node) {
return node.set("required", jnf.arrayNode().add(key));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ public static AnyOf anyOf(List<AbstractSchema<?>> anyOfs) {
return anyOf;
}

public static RequiredKey requiredKey(String key) {
return new RequiredKey(key);
}

public static Schema schema(String name) {
return new Schema(name);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,11 @@ public SchemaObject allOf(List<AbstractSchema<?>> allOf) {
public boolean hasProperty(String name) {
return properties.stream().anyMatch(p -> name.equals(p.getName()));
}

public AbstractSchema<?> getProperty(String name) {
return properties.stream()
.filter(p -> name.equals(p.getName()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No property: %s".formatted(name)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import com.networknt.schema.SchemaRegistry;
import com.predic8.membrane.annot.util.CompilerResult;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12;
import static com.predic8.membrane.annot.SpringConfigurationXSDGeneratingAnnotationProcessorTest.MC_MAIN_DEMO;
import static com.predic8.membrane.annot.util.CompilerHelper.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

class JsonSchemaGeneratorTest {

Expand Down Expand Up @@ -124,6 +130,132 @@ public void setAlgorithms(List<Algorithm> values) {}
assertEquals("[\"aes128_cbc\",\"rsa_1_5\"]", algorithms.at("/items/enum").toString());
}

@Test
void requiredChildWithOneImplementationIsRequired() throws Exception {
var schema = requiredChildrenSchema();
assertEquals("[\"child\"]", schema.at("/$defs/com_predic8_membrane_demo_config_spring_SingleParser/required").toString());
assertValid(schema, "single: { child: {} }");
assertInvalid(schema, "single: {}");
}

@Test
void requiredAbstractChildNeedsOneOfItsImplementations() throws Exception {
var schema = requiredChildrenSchema();
assertValid(schema, "cache: { memStore: {} }");
assertValid(schema, "cache: { diskStore: {} }");
assertInvalid(schema, "cache: {}");
}

@Test
void everyRequiredChildMustBePresent() throws Exception {
var schema = requiredChildrenSchema();
assertValid(schema, "two: { child: {}, memStore: {} }");
assertInvalid(schema, "two: { child: {} }");
assertInvalid(schema, "two: { memStore: {} }");
}

@Test
void refSatisfiesRequiredComponentChild() throws Exception {
var schema = requiredChildrenSchema();
assertValid(schema, "refCache: { sharedStore: {} }");
assertValid(schema, "refCache: { $ref: '#/components/store' }");
assertInvalid(schema, "refCache: {}");
}

private static final String REQUIRED_CHILDREN_SOURCES = """
package com.predic8.membrane.demo;
import com.predic8.membrane.annot.*;
@MCElement(name="single", topLevel=true, component=false)
public class SingleElement {
@Required
@MCChildElement
public void setChild(ChildElement child) {}
public ChildElement getChild() { return null; }
}
---
package com.predic8.membrane.demo;
import com.predic8.membrane.annot.*;
@MCElement(name="child", component=false)
public class ChildElement {}
---
package com.predic8.membrane.demo;
public abstract class Store {}
---
package com.predic8.membrane.demo;
import com.predic8.membrane.annot.*;
@MCElement(name="memStore", component=false)
public class MemStore extends Store {}
---
package com.predic8.membrane.demo;
import com.predic8.membrane.annot.*;
@MCElement(name="diskStore", component=false)
public class DiskStore extends Store {}
---
package com.predic8.membrane.demo;
import com.predic8.membrane.annot.*;
@MCElement(name="cache", topLevel=true, component=false)
public class CacheElement {
@Required
@MCChildElement
public void setStore(Store store) {}
public Store getStore() { return null; }
}
---
package com.predic8.membrane.demo;
import com.predic8.membrane.annot.*;
@MCElement(name="two", topLevel=true, component=false)
public class TwoElement {
@Required
@MCChildElement(order=1)
public void setChild(ChildElement child) {}
public ChildElement getChild() { return null; }

@Required
@MCChildElement(order=2)
public void setStore(Store store) {}
public Store getStore() { return null; }
}
---
package com.predic8.membrane.demo;
public abstract class SharedStore {}
---
package com.predic8.membrane.demo;
import com.predic8.membrane.annot.*;
@MCElement(name="sharedStore")
public class SharedStoreElement extends SharedStore {}
---
package com.predic8.membrane.demo;
import com.predic8.membrane.annot.*;
@MCElement(name="refCache", topLevel=true, component=false)
public class RefCacheElement {
@Required
@MCChildElement
public void setStore(SharedStore store) {}
public SharedStore getStore() { return null; }
}
""";

private static JsonNode requiredChildrenSchema() throws IOException {
var result = compile(splitSources(MC_MAIN_DEMO + REQUIRED_CHILDREN_SOURCES), false);
assertCompilerResult(true, result);
return schema(result);
}

private static void assertValid(JsonNode schema, String yaml) throws IOException {
final var errors = validate(schema, yaml);
assertTrue(errors.isEmpty(), () -> "Expected '%s' to be valid, but got %s".formatted(yaml, errors));
}

private static void assertInvalid(JsonNode schema, String yaml) throws IOException {
assertFalse(validate(schema, yaml).isEmpty(), () -> "Expected '%s' to be invalid".formatted(yaml));
}

private static List<com.networknt.schema.Error> validate(JsonNode schema, String yaml) throws IOException {
return SchemaRegistry.withDefaultDialect(DRAFT_2020_12)
.getSchema(schema)
.validate(new YAMLMapper().readTree(yaml));
}

private static JsonNode schema(CompilerResult result) throws IOException {
try (InputStream is = result.classLoader().getResourceAsStream(SCHEMA_RESOURCE)) {
assertNotNull(is, "membrane.schema.json was not generated");
Expand Down
Loading