Skip to content

feat(crd-generator): introduce @JSONSchema annotation #7000

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
* Fix #6880: LogWatch interface provides listeners on close stream event
* Fix #6971: Exposed Istio v1 models in Istio Client DSL
* Fix #6998: Removed unneeded dependency on javax.annotation:javax.annotation-api
* Fix #6999: (crd-generator) introduce JSONSchema annotation for increased control of schema output

#### Dependency Upgrade
* Fix #6829: Sundrio was upgraded to 0.200.3. In some rare circumstances nested method names will need to be changed.
Expand All @@ -54,7 +55,7 @@

#### New Features
* Fix #5993: Support for Kubernetes v1.31 (elli)
* Fix #6767: Support for Kubernetes v1.32 (penelope)
* Fix #6767: Support for Kubernetes v1.32 (penelope)
* Fix #6777: Added Javadoc comments to all generated models
* Fix #6802: (java-generator) Added support for required spec and status

Expand Down Expand Up @@ -225,7 +226,7 @@
* Fix #5357: adding additional Quantity methods
* Fix #5635: refined LeaderElector lifecycle and logging
* Fix #5787: (crd-generator) add support for deprecated versions for generated CRDs
* Fix #5788: (crd-generator) add support for Kubernetes validation rules
* Fix #5788: (crd-generator) add support for Kubernetes validation rules
* Fix #5735: Replace usages of `UUID.randomUUID()` with UUID created via AtomicLong

#### New Features
Expand Down Expand Up @@ -309,7 +310,7 @@
* Fix #5220: refinements and clarifications to the validation of names

#### Dependency Upgrade
* Fix #5286: Update Fabric8 OpenShift Model as per OpenShift 4.13.12
* Fix #5286: Update Fabric8 OpenShift Model as per OpenShift 4.13.12
* Fix #5373: Gradle base API based on v8.2.1
* Fix #5401: Upgrade Fabric8 Kubernetes Model to Kubernetes v1.28.2

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import io.fabric8.crdv2.generator.InternalSchemaSwaps.SwapResult;
import io.fabric8.crdv2.generator.ResolvingContext.GeneratorObjectSchema;
import io.fabric8.generator.annotation.Default;
import io.fabric8.generator.annotation.JSONSchema;
import io.fabric8.generator.annotation.Max;
import io.fabric8.generator.annotation.Min;
import io.fabric8.generator.annotation.Nullable;
Expand Down Expand Up @@ -73,7 +74,6 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
Expand All @@ -82,6 +82,8 @@
import java.util.Set;
import java.util.TreeMap;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

Expand All @@ -97,7 +99,7 @@ public abstract class AbstractJsonSchema<T extends KubernetesJSONSchemaProps, V

private static final Logger LOGGER = LoggerFactory.getLogger(AbstractJsonSchema.class);

private final ResolvingContext resolvingContext;
protected final ResolvingContext resolvingContext;
private final T root;
private final Set<String> dependentClasses = new HashSet<>();
private final Set<AdditionalPrinterColumn> additionalPrinterColumns = new HashSet<>();
Expand Down Expand Up @@ -409,10 +411,8 @@ private Optional<Long> findMaxInSizeAnnotation(BeanProperty beanProperty) {

private T resolveObject(LinkedHashMap<String, String> visited, InternalSchemaSwaps schemaSwaps, JsonSchema jacksonSchema,
String... ignore) {
Set<String> ignores = ignore.length > 0 ? new LinkedHashSet<>(Arrays.asList(ignore)) : Collections.emptySet();

final T objectSchema = singleProperty("object");

Set<String> ignores = Set.of(ignore);
schemaSwaps = schemaSwaps.branchAnnotations();
final InternalSchemaSwaps swaps = schemaSwaps;

Expand All @@ -426,6 +426,16 @@ private T resolveObject(LinkedHashMap<String, String> visited, InternalSchemaSwa
Class<?> rawClass = gos.javaType.getRawClass();
collectDependentClasses(rawClass);

T classSchema = resolveSchemaAnnotation(
rawClass.getDeclaredAnnotation(JSONSchema.class),
rawClass,
true,
resolvingContext.ignoreJSONSchemaAnnotation);

if (classSchema != null) {
return classSchema;
}

consumeRepeatingAnnotation(rawClass, SchemaSwap.class, ss -> {
swaps.registerSwap(rawClass,
ss.originalType(),
Expand All @@ -434,22 +444,31 @@ private T resolveObject(LinkedHashMap<String, String> visited, InternalSchemaSwa
});

List<String> required = new ArrayList<>();
final T objectSchema = singleProperty("object");

for (Map.Entry<String, JsonSchema> property : new TreeMap<>(gos.getProperties()).entrySet()) {
for (Map.Entry<String, JsonSchema> property : visibleProperties(gos.getProperties(), ignores).entrySet()) {
String name = property.getKey();
if (ignores.contains(name)) {
BeanProperty beanProperty = gos.beanProperties.get(property.getKey());
Utils.checkNotNull(beanProperty, "CRD generation works only with bean properties");

T propSchema = resolveSchemaAnnotation(
beanProperty.getAnnotation(JSONSchema.class),
beanProperty.getType().getRawClass(),
false,
false);

if (propSchema != null) {
addProperty(name, objectSchema, propSchema);
continue;
}

schemaSwaps = schemaSwaps.branchDepths();
SwapResult swapResult = schemaSwaps.lookupAndMark(rawClass, name);
LinkedHashMap<String, String> savedVisited = visited;
if (swapResult.onGoing) {
visited = new LinkedHashMap<>();
}

BeanProperty beanProperty = gos.beanProperties.get(property.getKey());
Utils.checkNotNull(beanProperty, "CRD generation works only with bean properties");

JsonSchema propertySchema = property.getValue();
PropertyMetadata propertyMetadata = new PropertyMetadata(propertySchema, beanProperty);

Expand Down Expand Up @@ -500,6 +519,20 @@ private T resolveObject(LinkedHashMap<String, String> visited, InternalSchemaSwa
return objectSchema;
}

private T resolveSchemaAnnotation(JSONSchema annotation, Class<?> rawClass, boolean isTargetType, boolean ignoreAnnotation) {
if (annotation != null && !ignoreAnnotation) {
return fromAnnotation(rawClass, isTargetType, annotation);
}
return null;
}

private static Map<String, JsonSchema> visibleProperties(Map<String, JsonSchema> properties, Set<String> ignores) {
return new TreeMap<>(
properties.entrySet().stream()
.filter(e -> !ignores.contains(e.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
}

private void collectDependentClasses(Class<?> rawClass) {
if (rawClass != null && !rawClass.getName().startsWith("java.") && dependentClasses.add(rawClass.getName())) {
Stream.of(rawClass.getInterfaces()).forEach(this::collectDependentClasses);
Expand Down Expand Up @@ -680,7 +713,73 @@ private Set<String> findIgnoredEnumConstants(JavaType type) {
return toIgnore;
}

V from(ValidationRule validationRule) {
protected T fromAnnotation(Class<?> rawClass, boolean isTargetType, JSONSchema schema) {
T result = mapImplementation(schema.implementation(), isTargetType);

if (result == null) {
result = singleProperty(null);
}

setIfDefined(schema.defaultValue(), v -> parseJson(v, rawClass), result::setDefault);
setIfDefined(schema.description(), result::setDescription);
setIfDefined(schema.exclusiveMaximum(), this::mapBoolean, result::setExclusiveMaximum);
setIfDefined(schema.exclusiveMinimum(), this::mapBoolean, result::setExclusiveMinimum);
setIfDefined(schema.format(), result::setFormat);
setIfDefined(schema.maximum(), result::setMaximum);
setIfDefined(schema.maxItems(), result::setMaxItems);
setIfDefined(schema.maxLength(), result::setMaxLength);
setIfDefined(schema.maxProperties(), result::setMaxProperties);
setIfDefined(schema.minimum(), result::setMinimum);
setIfDefined(schema.minItems(), result::setMinItems);
setIfDefined(schema.minLength(), result::setMinLength);
setIfDefined(schema.minProperties(), result::setMinProperties);
setIfDefined(schema.nullable(), this::mapBoolean, result::setNullable);
setIfDefined(schema.pattern(), result::setPattern);
setIfDefined(schema.required(), ArrayList::new, Arrays::asList, result::setRequired);
setIfDefined(schema.xKubernetesPreserveUnknownFields(), this::mapBoolean, result::setXKubernetesPreserveUnknownFields);
return result;
}

protected static <A, M> void setIfDefined(A value, Function<A, M> transformer, Consumer<M> mutator) {
setIfDefined(value, () -> null, transformer, mutator);
}

protected static <A> void setIfDefined(A value, Consumer<A> mutator) {
setIfDefined(value, () -> null, Function.identity(), mutator);
}

protected static <A, M> void setIfDefined(A value, Supplier<M> defaultValue, Function<A, M> transformer,
Consumer<M> mutator) {
if (JSONSchema.Undefined.isUndefined(value)) {
// Not defined in the annotation (the default), don't touch the model.
} else if (JSONSchema.Suppressed.isSuppressed(value)) {
// Suppressed in the annotation, return the model back to the default value.
mutator.accept(defaultValue.get());
} else {
mutator.accept(transformer.apply(value));
}
}

protected JsonNode parseJson(String value, Class<?> targetType) {
Optional<Class<?>> rawType = Optional.ofNullable(targetType);

try {
Object typedValue = resolvingContext.kubernetesSerialization.unmarshal(value, rawType.orElse(Object.class));
return resolvingContext.kubernetesSerialization.convertValue(typedValue, JsonNode.class);
} catch (Exception e) {
throw new IllegalArgumentException("Cannot parse value '" + value + "' as valid YAML or JSON.", e);
}
}

protected List<JsonNode> parseJson(String[] values, Class<?> targetType) {
return Arrays.stream(values).map(value -> parseJson(value, targetType)).collect(Collectors.toList());
}

protected <A extends JSONSchema.Boolean> Boolean mapBoolean(Class<A> value) {
return value == JSONSchema.True.class ? Boolean.TRUE : Boolean.FALSE;
}

protected V from(ValidationRule validationRule) {
V result = newKubernetesValidationRule();
result.setRule(validationRule.value());
result.setReason(mapNotEmpty(validationRule.reason()));
Expand All @@ -695,6 +794,8 @@ private static String mapNotEmpty(String s) {
return Utils.isNullOrEmpty(s) ? null : s;
}

protected abstract T mapImplementation(Class<?> value, boolean isTargetType);

protected abstract V newKubernetesValidationRule();

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public JsonObjectFormatVisitor expectObjectFormat(JavaType convertedType) {
final KubernetesSerialization kubernetesSerialization;
final Map<String, GeneratorObjectSchema> uriToJacksonSchema;
final boolean implicitPreserveUnknownFields;
final boolean ignoreJSONSchemaAnnotation;

private static ObjectMapper OBJECT_MAPPER;

Expand All @@ -112,21 +113,29 @@ public static ResolvingContext defaultResolvingContext(boolean implicitPreserveU
}

public ResolvingContext forkContext() {
return new ResolvingContext(objectMapper, kubernetesSerialization, uriToJacksonSchema, implicitPreserveUnknownFields);
return new ResolvingContext(objectMapper, kubernetesSerialization, uriToJacksonSchema, implicitPreserveUnknownFields,
ignoreJSONSchemaAnnotation);
}

public ResolvingContext forkContext(boolean ignoreJSONSchemaAnnotation) {
return new ResolvingContext(objectMapper, kubernetesSerialization, uriToJacksonSchema, implicitPreserveUnknownFields,
ignoreJSONSchemaAnnotation);
}

public ResolvingContext(ObjectMapper mapper, KubernetesSerialization kubernetesSerialization,
boolean implicitPreserveUnknownFields) {
this(mapper, kubernetesSerialization, new ConcurrentHashMap<>(), implicitPreserveUnknownFields);
this(mapper, kubernetesSerialization, new ConcurrentHashMap<>(), implicitPreserveUnknownFields, false);
}

private ResolvingContext(ObjectMapper mapper, KubernetesSerialization kubernetesSerialization,
Map<String, GeneratorObjectSchema> uriToJacksonSchema,
boolean implicitPreserveUnknownFields) {
boolean implicitPreserveUnknownFields,
boolean ignoreJSONSchemaAnnotation) {
this.uriToJacksonSchema = uriToJacksonSchema;
this.objectMapper = mapper;
this.kubernetesSerialization = kubernetesSerialization;
this.implicitPreserveUnknownFields = implicitPreserveUnknownFields;
this.ignoreJSONSchemaAnnotation = ignoreJSONSchemaAnnotation;
generator = new JsonSchemaGenerator(mapper, new WrapperFactory() {

@Override
Expand Down
Loading
Loading