From 25dd00247d59ed22e9f8ee68b3ff15fae7a5d70f Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Thu, 10 Apr 2025 14:37:16 -0400 Subject: [PATCH 1/6] feat(crd-generator): introduce `@JSONSchema` annotation Signed-off-by: Michael Edgar --- .../crdv2/generator/AbstractJsonSchema.java | 116 +++++++++- .../crdv2/generator/ResolvingContext.java | 13 +- .../crdv2/generator/v1/JsonSchema.java | 138 ++++++++++++ .../generator/annotation/JSONSchema.java | 200 ++++++++++++++++++ 4 files changed, 457 insertions(+), 10 deletions(-) create mode 100644 generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java index b99e5b85052..f13a2eb8005 100644 --- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java +++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java @@ -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; @@ -82,6 +83,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -97,7 +99,7 @@ public abstract class AbstractJsonSchema dependentClasses = new HashSet<>(); private final Set additionalPrinterColumns = new HashSet<>(); @@ -165,6 +167,14 @@ private T resolveRoot(Class definition) { resolvingContext.objectMapper.getSerializationConfig().constructType(definition), schema, null); } + private T mapAnnotation(A annotation, + Function mapper) { + if (annotation != null) { + return mapper.apply(annotation); + } + return null; + } + /** * Walks up the class hierarchy to consume the repeating annotation */ @@ -411,8 +421,6 @@ private T resolveObject(LinkedHashMap visited, InternalSchemaSwa String... ignore) { Set ignores = ignore.length > 0 ? new LinkedHashSet<>(Arrays.asList(ignore)) : Collections.emptySet(); - final T objectSchema = singleProperty("object"); - schemaSwaps = schemaSwaps.branchAnnotations(); final InternalSchemaSwaps swaps = schemaSwaps; @@ -426,6 +434,13 @@ private T resolveObject(LinkedHashMap visited, InternalSchemaSwa Class rawClass = gos.javaType.getRawClass(); collectDependentClasses(rawClass); + JSONSchema schemaAnnotation = resolvingContext.ignoreJSONSchemaAnnotation ? null : rawClass.getDeclaredAnnotation(JSONSchema.class); + T classSchema = mapAnnotation(schemaAnnotation, schema -> fromAnnotation(rawClass, schema)); + + if (classSchema != null) { + return classSchema; + } + consumeRepeatingAnnotation(rawClass, SchemaSwap.class, ss -> { swaps.registerSwap(rawClass, ss.originalType(), @@ -434,12 +449,25 @@ private T resolveObject(LinkedHashMap visited, InternalSchemaSwa }); List required = new ArrayList<>(); + final T objectSchema = singleProperty("object"); for (Map.Entry property : new TreeMap<>(gos.getProperties()).entrySet()) { String name = property.getKey(); if (ignores.contains(name)) { continue; } + BeanProperty beanProperty = gos.beanProperties.get(property.getKey()); + Utils.checkNotNull(beanProperty, "CRD generation works only with bean properties"); + + Class propRawClass = beanProperty.getType().getRawClass(); + JSONSchema propSchemaAnnotation = beanProperty.getAnnotation(JSONSchema.class); + T propSchema = mapAnnotation(propSchemaAnnotation, schema -> fromAnnotation(propRawClass, schema)); + + if (propSchema != null) { + addProperty(name, objectSchema, propSchema); + continue; + } + schemaSwaps = schemaSwaps.branchDepths(); SwapResult swapResult = schemaSwaps.lookupAndMark(rawClass, name); LinkedHashMap savedVisited = visited; @@ -447,9 +475,6 @@ private T resolveObject(LinkedHashMap visited, InternalSchemaSwa 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); @@ -680,7 +705,82 @@ private Set findIgnoredEnumConstants(JavaType type) { return toIgnore; } - V from(ValidationRule validationRule) { + protected T fromAnnotation(Class targetType, JSONSchema schema) { + T result = mapImplementation(schema.implementation()); + + if (result == null) { + result = singleProperty(mapDefined(schema.type())); + } + + setIfDefined(mapDefined(schema.defaultValue(), targetType), result::setDefault); + setIfDefined(mapDefined(schema.description()), result::setDescription); + setIfDefined(mapBoolean(schema.exclusiveMaximum()), result::setExclusiveMaximum); + setIfDefined(mapBoolean(schema.exclusiveMinimum()), result::setExclusiveMinimum); + setIfDefined(mapDefined(schema.format()), result::setFormat); + setIfDefined(mapDefined(schema.maximum()), result::setMaximum); + setIfDefined(mapDefined(schema.maxItems()), result::setMaxItems); + setIfDefined(mapDefined(schema.maxLength()), result::setMaxLength); + setIfDefined(mapDefined(schema.maxProperties()), result::setMaxProperties); + setIfDefined(mapDefined(schema.minimum()), result::setMinimum); + setIfDefined(mapDefined(schema.minItems()), result::setMinItems); + setIfDefined(mapDefined(schema.minLength()), result::setMinLength); + setIfDefined(mapDefined(schema.minProperties()), result::setMinProperties); + setIfDefined(mapBoolean(schema.nullable()), result::setNullable); + setIfDefined(mapDefined(schema.pattern()), result::setPattern); + setIfDefined(mapDefined(schema.required()), result::setRequired); + setIfDefined(mapBoolean(schema.xKubernetesPreserveUnknownFields()), result::setXKubernetesPreserveUnknownFields); + return result; + } + + protected static

void setIfDefined(P value, Consumer

mutator) { + if (value != null) { + mutator.accept(value); + } + } + + protected JsonNode mapDefined(String value, Class targetType) { + if ((value = mapDefined(value)) == null) { + return null; + } + + Optional> 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) { + if (value.isEmpty()) { + LOGGER.warn("Cannot parse value '{}' from JSONSchema annotation as valid YAML or JSON, no value will be used.", value); + return null; + } + throw new IllegalArgumentException("Cannot parse value '" + value + "' as valid YAML or JSON.", e); + } + } + + protected static String mapDefined(String value) { + return JSONSchema.Undefined.STRING.equals(value) ? null : value; + } + + protected static List mapDefined(String[] values) { + return values.length == 0 ? null : List.of(values); + } + + protected static Double mapDefined(double value) { + return JSONSchema.Undefined.DOUBLE == value ? null : value; + } + + protected static Long mapDefined(long value) { + return JSONSchema.Undefined.LONG == value ? null : value; + } + + protected static Boolean mapBoolean(Class value) { + if (value == JSONSchema.Undefined.class) { + return null; // NOSONAR + } + 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())); @@ -695,6 +795,8 @@ private static String mapNotEmpty(String s) { return Utils.isNullOrEmpty(s) ? null : s; } + protected abstract T mapImplementation(Class value); + protected abstract V newKubernetesValidationRule(); /** diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/ResolvingContext.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/ResolvingContext.java index 785480ec384..5ae465c4c3d 100644 --- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/ResolvingContext.java +++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/ResolvingContext.java @@ -93,6 +93,7 @@ public JsonObjectFormatVisitor expectObjectFormat(JavaType convertedType) { final KubernetesSerialization kubernetesSerialization; final Map uriToJacksonSchema; final boolean implicitPreserveUnknownFields; + final boolean ignoreJSONSchemaAnnotation; private static ObjectMapper OBJECT_MAPPER; @@ -112,21 +113,27 @@ 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 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 diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java index 18fff6c2f8a..239215b3a84 100644 --- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java +++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java @@ -22,13 +22,21 @@ import io.fabric8.crdv2.generator.ResolvingContext; import io.fabric8.crdv2.generator.v1.JsonSchema.V1JSONSchemaProps; import io.fabric8.crdv2.generator.v1.JsonSchema.V1ValidationRule; +import io.fabric8.generator.annotation.JSONSchema; +import io.fabric8.kubernetes.api.model.apiextensions.v1.ExternalDocumentation; +import io.fabric8.kubernetes.api.model.apiextensions.v1.ExternalDocumentationBuilder; import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaPropsOrArray; import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaPropsOrBool; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaPropsOrStringArray; import io.fabric8.kubernetes.api.model.apiextensions.v1.ValidationRule; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class JsonSchema extends AbstractJsonSchema { @@ -107,4 +115,134 @@ protected V1JSONSchemaProps raw() { return result; } + @Override + protected V1JSONSchemaProps fromAnnotation(Class targetType, JSONSchema schema) { + V1JSONSchemaProps result = super.fromAnnotation(targetType, schema); + setIfDefined(mapDefined(schema.type()), result::setType); + setIfDefined(mapDefined(schema.$ref()), result::set$ref); + setIfDefined(mapDefined(schema.$schema()), result::set$schema); + setIfDefined(mapSchemaOrBool(schema.additionalItems()), result::setAdditionalItems); + setIfDefined(mapSchemaOrBool(schema.additionalProperties()), result::setAdditionalProperties); + setIfDefined(mapSchemaList(schema.allOf()), result::setAllOf); + setIfDefined(mapSchemaList(schema.anyOf()), result::setAnyOf); + setIfDefined(mapSchemaMap(schema.definitions()), result::setDefinitions); + setIfDefined(mapDependencies(schema.dependencies()), result::setDependencies); + setIfDefined(mapEnumeration(schema.enumeration(), targetType), result::setEnum); + setIfDefined(mapDefined(schema.example(), targetType), result::setExample); + setIfDefined(mapExternalDocs(schema.externalDocs()), result::setExternalDocs); + setIfDefined(mapDefined(schema.id()), result::setId); + setIfDefined(mapSchemaOrArray(schema.items()), result::setItems); + setIfDefined(mapDefined(schema.multipleOf()), result::setMultipleOf); + setIfDefined(mapSchema(schema.not()), result::setNot); + setIfDefined(mapSchemaList(schema.oneOf()), result::setOneOf); + setIfDefined(mapSchemaMap(schema.patternProperties()), result::setPatternProperties); + setIfDefined(mapSchemaMap(schema.properties()), result::setProperties); + setIfDefined(mapDefined(schema.title()), result::setTitle); + setIfDefined(mapBoolean(schema.uniqueItems()), result::setUniqueItems); + setIfDefined(mapBoolean(schema.xKubernetesEmbeddedResource()), result::setXKubernetesEmbeddedResource); + setIfDefined(mapBoolean(schema.xKubernetesIntOrString()), result::setXKubernetesIntOrString); + setIfDefined(mapDefined(schema.xKubernetesListMapKeys()), result::setXKubernetesListMapKeys); + setIfDefined(mapDefined(schema.xKubernetesListType()), result::setXKubernetesListType); + setIfDefined(mapDefined(schema.xKubernetesMapType()), result::setXKubernetesMapType); + setIfDefined(mapValidationRules(schema.xKubernetesValidations()), result::setXKubernetesValidations); + return result; + } + + @Override + protected V1JSONSchemaProps mapImplementation(Class value) { + if (value == JSONSchema.Undefined.class) { + return null; // NOSONAR + } + return new JsonSchema(resolvingContext.forkContext(true), value).getSchema(); + } + + private JSONSchemaProps mapSchema(Class value) { + if (value == JSONSchema.Undefined.class) { + return null; // NOSONAR + } + return new JsonSchema(resolvingContext.forkContext(false), value).getSchema(); + } + + private JSONSchemaPropsOrBool mapSchemaOrBool(Class value) { + if (value == JSONSchema.Undefined.class) { + return null; // NOSONAR + } + JSONSchemaPropsOrBool result = new JSONSchemaPropsOrBool(); + + if (JSONSchema.Boolean.class.isAssignableFrom(value)) { + @SuppressWarnings("unchecked") + Class booleanType = (Class) value; + result.setAllows(mapBoolean(booleanType)); + } else { + result.setSchema(new JsonSchema(resolvingContext.forkContext(false), value).getSchema()); + } + + return result; + } + + private List mapEnumeration(String[] examples, Class targetType) { + if (examples.length != 0) { + return Arrays.stream(examples).map(ex -> mapDefined(ex, targetType)).collect(Collectors.toList()); + } + return null; // NOSONAR + } + + private JSONSchemaPropsOrArray mapSchemaOrArray(Class[] values) { + return Optional.ofNullable(mapSchemaList(values)) + .map(schemas -> { + JSONSchemaPropsOrArray result = new JSONSchemaPropsOrArray(); + if (schemas.size() == 1) { + result.setSchema(schemas.get(0)); + } else { + result.setJSONSchemas(schemas); + } + return result; + }) + .orElse(null); + } + + private List mapSchemaList(Class[] values) { + if (values.length == 0) { + return null; // NOSONAR + } + + return Arrays.stream(values) + .map(this::mapSchema) + .collect(Collectors.toList()); + } + + private Map mapSchemaMap(JSONSchema.Map[] entries) { + if (entries.length == 0) { + return null; // NOSONAR + } + + return Arrays.stream(entries) + .map(e -> Map.entry(e.name(), mapSchema(e.value()))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private Map mapDependencies(JSONSchema.DependencyMap[] entries) { + if (entries.length == 0) { + return null; // NOSONAR + } + + return Arrays.stream(entries) + .map(e -> Map.entry(e.name(), new JSONSchemaPropsOrStringArray(mapDefined(e.value().properties()), mapSchema(e.value().schema())))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private ExternalDocumentation mapExternalDocs(JSONSchema.ExternalDocumentation externalDocs) { + if (Stream.of(externalDocs.description(), externalDocs.url()).allMatch(JSONSchema.Undefined.STRING::equals)) { + return null; + } + + return new ExternalDocumentationBuilder() + .withDescription(mapDefined(externalDocs.description())) + .withUrl(mapDefined(externalDocs.url())) + .build(); + } + + private List mapValidationRules(io.fabric8.generator.annotation.ValidationRule[] values) { + return Arrays.stream(values).map(super::from).collect(Collectors.toList()); + } } diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java new file mode 100644 index 00000000000..287e900b563 --- /dev/null +++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java @@ -0,0 +1,200 @@ +package io.fabric8.generator.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface JSONSchema { + + /** + * Marker interface used to restrict the class types that may be used for + * properties of type boolean. This enables readers of the annotation to + * distinguish between true, false, and undefined values. + */ + interface Boolean { + } + + /** + * Marker class used as a default for annotation properties of type + * {@code Class}. + */ + public static final class Undefined implements Boolean { + /** + * Marker value used as a default for annotation properties of type + * {@code String}. + */ + public static final String STRING = "io.fabric8.generator.annotation.JSONSchema.UNSET"; + + /** + * Marker value used as a default for annotation properties of type + * {@code double}. + */ + public static final double DOUBLE = Double.POSITIVE_INFINITY; + + /** + * Marker value used as a default for annotation properties of type + * {@code long}. Same binary value used for Double.POSITIVE_INFINITY. + */ + public static final long LONG = 0x7ff0000000000000L; + + private Undefined() { + } + } + + /** + * Marker class to indicate that a boolean {@code true} schema should be used. + * Additionally, this class is used to set a value of {@code true} for + * properties that allow true, false, or undefined boolean values. + */ + public static final class True implements Boolean { + private True() { + } + } + + /** + * Marker class to indicate that a boolean {@code false} schema should be used. + * Additionally, this class is used to set a value of {@code false} for + * properties that allow true, false, or undefined boolean values. + */ + public static final class False implements Boolean { + private False() { + } + } + + @Target({}) + @Retention(RetentionPolicy.RUNTIME) + public @interface Map { + String name(); + + Class value(); + } + + @Target({}) + @Retention(RetentionPolicy.RUNTIME) + public @interface Dependency { + String[] properties() default {}; + + Class schema() default Undefined.class; + } + + @Target({}) + @Retention(RetentionPolicy.RUNTIME) + public @interface DependencyMap { + String name(); + + Dependency value(); + } + + @Target({}) + @Retention(RetentionPolicy.RUNTIME) + public @interface ExternalDocumentation { + String description() default Undefined.STRING; + + String url() default Undefined.STRING; + } + + /** + * The implementation class allows for an additional type to be scanned as the + * basis for this schema. After scanning the implementation (if specified), the + * remaining properties will be set from this annotation, possibly overriding + * those determine by scanning the implementation class. + */ + Class implementation() default Undefined.class; + + String $ref() default Undefined.STRING; // NOSONAR + + String $schema() default Undefined.STRING; // NOSONAR + + Class additionalItems() default Undefined.class; + + Class additionalProperties() default Undefined.class; + + Class[] allOf() default {}; + + Class[] anyOf() default {}; + + String defaultValue() default Undefined.STRING; + + Map[] definitions() default {}; + + DependencyMap[] dependencies() default {}; + + String description() default Undefined.STRING; + + /* + * Parsed into com.fasterxml.jackson.databind.JsonNode depending on `type` + */ + String[] enumeration() default {}; + + /* + * Parsed into com.fasterxml.jackson.databind.JsonNode depending on `type` + */ + String example() default Undefined.STRING; + + Class exclusiveMaximum() default Undefined.class; + + Class exclusiveMinimum() default Undefined.class; + + ExternalDocumentation externalDocs() default @ExternalDocumentation; + + String format() default Undefined.STRING; + + String id() default Undefined.STRING; + + Class[] items() default {}; + + long maxItems() default Undefined.LONG; + + long maxLength() default Undefined.LONG; + + long maxProperties() default Undefined.LONG; + + double maximum() default Undefined.DOUBLE; + + long minItems() default Undefined.LONG; + + long minLength() default Undefined.LONG; + + long minProperties() default Undefined.LONG; + + double minimum() default Undefined.DOUBLE; + + double multipleOf() default Undefined.DOUBLE; + + Class not() default Undefined.class; + + Class nullable() default Undefined.class; + + Class[] oneOf() default {}; + + String pattern() default Undefined.STRING; + + Map[] patternProperties() default {}; + + Map[] properties() default {}; + + String[] required() default {}; + + String title() default Undefined.STRING; + + String type() default Undefined.STRING; + + Class uniqueItems() default Undefined.class; + + Class xKubernetesEmbeddedResource() default Undefined.class; + + Class xKubernetesIntOrString() default Undefined.class; + + String[] xKubernetesListMapKeys() default {}; + + String xKubernetesListType() default Undefined.STRING; + + String xKubernetesMapType() default Undefined.STRING; + + Class xKubernetesPreserveUnknownFields() default Undefined.class; + + ValidationRule[] xKubernetesValidations() default {}; +} \ No newline at end of file From 7ad9f7586ee713a64ebb98f5cdd8b08de69a4fed Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Fri, 11 Apr 2025 09:59:12 -0400 Subject: [PATCH 2/6] Fixes, add test cases Signed-off-by: Michael Edgar --- .../crdv2/generator/AbstractJsonSchema.java | 12 +- .../crdv2/generator/v1/JsonSchema.java | 13 +- .../example/jsonschema/JsonSchemaAnno.java | 7 + .../jsonschema/JsonSchemaAnnoSpec.java | 102 +++++++ .../jsonschema/JsonSchemaAnnoStatus.java | 8 + .../v1/JsonSchemaAnnotationTest.java | 105 +++++++ .../generator/annotation/JSONSchema.java | 267 +++++++++--------- 7 files changed, 376 insertions(+), 138 deletions(-) create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnno.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoStatus.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java index f13a2eb8005..935cb2ce4d9 100644 --- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java +++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java @@ -435,7 +435,7 @@ private T resolveObject(LinkedHashMap visited, InternalSchemaSwa collectDependentClasses(rawClass); JSONSchema schemaAnnotation = resolvingContext.ignoreJSONSchemaAnnotation ? null : rawClass.getDeclaredAnnotation(JSONSchema.class); - T classSchema = mapAnnotation(schemaAnnotation, schema -> fromAnnotation(rawClass, schema)); + T classSchema = mapAnnotation(schemaAnnotation, schema -> fromAnnotation(rawClass, true, schema)); if (classSchema != null) { return classSchema; @@ -461,7 +461,7 @@ private T resolveObject(LinkedHashMap visited, InternalSchemaSwa Class propRawClass = beanProperty.getType().getRawClass(); JSONSchema propSchemaAnnotation = beanProperty.getAnnotation(JSONSchema.class); - T propSchema = mapAnnotation(propSchemaAnnotation, schema -> fromAnnotation(propRawClass, schema)); + T propSchema = mapAnnotation(propSchemaAnnotation, schema -> fromAnnotation(propRawClass, false, schema)); if (propSchema != null) { addProperty(name, objectSchema, propSchema); @@ -705,14 +705,14 @@ private Set findIgnoredEnumConstants(JavaType type) { return toIgnore; } - protected T fromAnnotation(Class targetType, JSONSchema schema) { - T result = mapImplementation(schema.implementation()); + protected T fromAnnotation(Class rawClass, boolean isTargetType, JSONSchema schema) { + T result = mapImplementation(schema.implementation(), isTargetType); if (result == null) { result = singleProperty(mapDefined(schema.type())); } - setIfDefined(mapDefined(schema.defaultValue(), targetType), result::setDefault); + setIfDefined(mapDefined(schema.defaultValue(), rawClass), result::setDefault); setIfDefined(mapDefined(schema.description()), result::setDescription); setIfDefined(mapBoolean(schema.exclusiveMaximum()), result::setExclusiveMaximum); setIfDefined(mapBoolean(schema.exclusiveMinimum()), result::setExclusiveMinimum); @@ -795,7 +795,7 @@ private static String mapNotEmpty(String s) { return Utils.isNullOrEmpty(s) ? null : s; } - protected abstract T mapImplementation(Class value); + protected abstract T mapImplementation(Class value, boolean isTargetType); protected abstract V newKubernetesValidationRule(); diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java index 239215b3a84..a27e9f6566d 100644 --- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java +++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java @@ -116,8 +116,9 @@ protected V1JSONSchemaProps raw() { } @Override - protected V1JSONSchemaProps fromAnnotation(Class targetType, JSONSchema schema) { - V1JSONSchemaProps result = super.fromAnnotation(targetType, schema); + protected V1JSONSchemaProps fromAnnotation(Class rawClass, boolean isTargetType, JSONSchema schema) { + V1JSONSchemaProps result = super.fromAnnotation(rawClass, isTargetType, schema); + // maybe override the type if it was determined by reading the optional `implementation` setIfDefined(mapDefined(schema.type()), result::setType); setIfDefined(mapDefined(schema.$ref()), result::set$ref); setIfDefined(mapDefined(schema.$schema()), result::set$schema); @@ -127,8 +128,8 @@ protected V1JSONSchemaProps fromAnnotation(Class targetType, JSONSchema schem setIfDefined(mapSchemaList(schema.anyOf()), result::setAnyOf); setIfDefined(mapSchemaMap(schema.definitions()), result::setDefinitions); setIfDefined(mapDependencies(schema.dependencies()), result::setDependencies); - setIfDefined(mapEnumeration(schema.enumeration(), targetType), result::setEnum); - setIfDefined(mapDefined(schema.example(), targetType), result::setExample); + setIfDefined(mapEnumeration(schema.enumeration(), rawClass), result::setEnum); + setIfDefined(mapDefined(schema.example(), rawClass), result::setExample); setIfDefined(mapExternalDocs(schema.externalDocs()), result::setExternalDocs); setIfDefined(mapDefined(schema.id()), result::setId); setIfDefined(mapSchemaOrArray(schema.items()), result::setItems); @@ -149,11 +150,11 @@ protected V1JSONSchemaProps fromAnnotation(Class targetType, JSONSchema schem } @Override - protected V1JSONSchemaProps mapImplementation(Class value) { + protected V1JSONSchemaProps mapImplementation(Class value, boolean isTargetType) { if (value == JSONSchema.Undefined.class) { return null; // NOSONAR } - return new JsonSchema(resolvingContext.forkContext(true), value).getSchema(); + return new JsonSchema(resolvingContext.forkContext(isTargetType), value).getSchema(); } private JSONSchemaProps mapSchema(Class value) { diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnno.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnno.java new file mode 100644 index 00000000000..f498d759059 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnno.java @@ -0,0 +1,7 @@ +package io.fabric8.crdv2.example.jsonschema; + +import io.fabric8.kubernetes.client.CustomResource; + +public class JsonSchemaAnno extends CustomResource { + private static final long serialVersionUID = 1L; +} diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java new file mode 100644 index 00000000000..dd307fb7189 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java @@ -0,0 +1,102 @@ +package io.fabric8.crdv2.example.jsonschema; + +import io.fabric8.generator.annotation.JSONSchema; +import io.fabric8.generator.annotation.JSONSchema.ExternalDocumentation; +import lombok.Data; + +import java.util.List; + +@Data +public class JsonSchemaAnnoSpec { + + @JSONSchema(type = "string") + private Object customizedType; + + /* *********************************************************************** */ + + @JSONSchema(externalDocs = @ExternalDocumentation(url = "https://example.com/docs.txt")) + private String documentedExternally; + + /* *********************************************************************** */ + + @JSONSchema(implementation = StrictItemsSchema.class, additionalProperties = JSONSchema.False.class) + @Data + static class StrictItemsSchema { + String field1; + int field2; + } + + @JSONSchema(type = "array", additionalItems = JSONSchema.False.class, items = StrictItemsSchema.class) + private List strictItems; + + /* *********************************************************************** */ + + @JSONSchema(implementation = LaxItemsSchema1.class, additionalProperties = JSONSchema.True.class) + @Data + static class LaxItemsSchema1 { + String field1; + double field2; + } + + // besides field1 and field2, allows additional properties if they are objects of type LaxItemsSchema1 + @JSONSchema(implementation = LaxItemsSchema2.class, additionalProperties = LaxItemsSchema1.class) + @Data + static class LaxItemsSchema2 { + String field1; + int field2; + } + + @JSONSchema(type = "array", additionalItems = JSONSchema.True.class, items = { LaxItemsSchema1.class, + LaxItemsSchema2.class }) + private List laxItems; + + /* *********************************************************************** */ + + @JSONSchema(implementation = OverriddenPropertiesSchema.class, description = "Has properties that are replaced by referencing type") + @Data + static class OverriddenPropertiesSchema { + String field1; + int field2; + } + + @JSONSchema(type = "object", properties = { + @JSONSchema.Map(name = "field3", value = Long.class), + @JSONSchema.Map(name = "field4", value = Double.class), + }, implementation = OverriddenPropertiesSchema.class) + private OverriddenPropertiesSchema overriddenProperties; + + /* *********************************************************************** */ + + @Data + static class ObjectEnumerationSchema { + String field1; + int field2; + } + + @JSONSchema(type = "object", implementation = ObjectEnumerationSchema.class, defaultValue = "{ \"field1\": \"allowedValue1\", \"field2\": 1 }", enumeration = { + "{ \"field1\": \"allowedValue1\", \"field2\": 1 }", + "{ \"field1\": \"allowedValue2\", \"field2\": 2 }", + }) + private OverriddenPropertiesSchema objectEnumeration; + + /* *********************************************************************** */ + + @JSONSchema(type = "integer", nullable = JSONSchema.True.class, minimum = 0d, maximum = 100d, exclusiveMaximum = JSONSchema.True.class) + static class NullableIntegerWithRange { + } + + @Data + @JSONSchema(type = "object", implementation = ObjectEnumerationSchema.class, dependencies = { + // field1 requires the presence of field2 + @JSONSchema.DependencyMap(name = "field1", value = @JSONSchema.Dependency(properties = "field2")), + // field2 requires that field1 is null or an integer between 0 and 100 (exclusive) + @JSONSchema.DependencyMap(name = "field2", value = @JSONSchema.Dependency(schema = NullableIntegerWithRange.class)) + }) + static class DependentPropertiesSchema { + String field1; + Integer field2; + } + + private DependentPropertiesSchema dependentProperties; + +} diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoStatus.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoStatus.java new file mode 100644 index 00000000000..be1ef9ac3d8 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoStatus.java @@ -0,0 +1,8 @@ +package io.fabric8.crdv2.example.jsonschema; + +import lombok.Data; + +@Data +public class JsonSchemaAnnoStatus { + +} diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java new file mode 100644 index 00000000000..bcc4f274197 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java @@ -0,0 +1,105 @@ +package io.fabric8.crdv2.generator.v1; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.fabric8.crdv2.example.jsonschema.JsonSchemaAnno; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaPropsOrStringArray; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JsonSchemaAnnotationTest { + + JSONSchemaProps schema; + + @BeforeEach + void setup() { + schema = JsonSchema.from(JsonSchemaAnno.class); + assertNotNull(schema); + } + + @Test + void testCustomizedType() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("customizedType"); + assertEquals("string", target.getType()); + } + + @Test + void testExternalDocumentation() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("documentedExternally"); + assertNull(target.getType()); // type not defined in @JSONSchema + assertNotNull(target.getExternalDocs()); + assertEquals("https://example.com/docs.txt", target.getExternalDocs().getUrl()); + } + + @Test + void testStrictArrayItemSchema() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("strictItems"); + assertEquals("array", target.getType()); + assertEquals(Boolean.FALSE, target.getAdditionalItems().getAllows()); + assertNotNull(target.getItems()); + JSONSchemaProps items = target.getItems().getSchema(); + assertEquals("object", items.getType()); + assertEquals(Boolean.FALSE, items.getAdditionalProperties().getAllows()); + assertTrue(items.getProperties().keySet().containsAll(List.of("field1", "field2"))); + } + + @Test + void testLaxArrayItemSchema() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("laxItems"); + assertEquals("array", target.getType()); + assertEquals(Boolean.TRUE, target.getAdditionalItems().getAllows()); + assertNotNull(target.getItems()); + List itemsSchemas = target.getItems().getJSONSchemas(); + assertEquals(2, itemsSchemas.size()); + assertTrue(itemsSchemas.stream().allMatch(s -> s.getProperties().keySet().containsAll(List.of("field1", "field2")))); + + assertEquals(Boolean.TRUE, itemsSchemas.get(0).getAdditionalProperties().getAllows()); + assertNull(itemsSchemas.get(0).getAdditionalProperties().getSchema()); + assertEquals(itemsSchemas.get(0), itemsSchemas.get(1).getAdditionalProperties().getSchema()); + assertNull(itemsSchemas.get(1).getAdditionalProperties().getAllows()); + } + + @Test + void testOverriddenProperties() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("overriddenProperties"); + assertEquals("object", target.getType()); + assertEquals(Set.of("field3", "field4"), target.getProperties().keySet()); + assertEquals("Has properties that are replaced by referencing type", target.getDescription()); + } + + @Test + void testObjectEnumerationWithDefault() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("objectEnumeration"); + ObjectNode expected1 = JsonNodeFactory.instance.objectNode().put("field1", "allowedValue1").put("field2", 1); + ObjectNode expected2 = JsonNodeFactory.instance.objectNode().put("field1", "allowedValue2").put("field2", 2); + assertEquals(expected1, target.getDefault()); + assertEquals(List.of(expected1, expected2), target.getEnum()); + } + + @Test + void testDependencies() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("dependentProperties"); + JSONSchemaPropsOrStringArray field1Deps = target.getDependencies().get("field1"); + assertNull(field1Deps.getSchema()); + assertEquals(List.of("field2"), field1Deps.getProperty()); + JSONSchemaPropsOrStringArray field2Deps = target.getDependencies().get("field2"); + assertNull(field2Deps.getProperty()); + assertEquals("integer", field2Deps.getSchema().getType()); + assertEquals(Boolean.TRUE, field2Deps.getSchema().getNullable()); + assertEquals(Double.valueOf(0), field2Deps.getSchema().getMinimum()); + assertNull(field2Deps.getSchema().getExclusiveMinimum()); + assertEquals(Double.valueOf(100), field2Deps.getSchema().getMaximum()); + assertEquals(Boolean.TRUE, field2Deps.getSchema().getExclusiveMaximum()); + } + +} + diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java index 287e900b563..53a6356d428 100644 --- a/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java +++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2025 Red Hat, Inc. + * + * 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 io.fabric8.generator.annotation; import java.lang.annotation.ElementType; @@ -9,192 +24,192 @@ @Retention(RetentionPolicy.RUNTIME) public @interface JSONSchema { + /** + * Marker interface used to restrict the class types that may be used for + * properties of type boolean. This enables readers of the annotation to + * distinguish between true, false, and undefined values. + */ + interface Boolean { + } + + /** + * Marker class used as a default for annotation properties of type + * {@code Class}. + */ + public static final class Undefined implements Boolean { /** - * Marker interface used to restrict the class types that may be used for - * properties of type boolean. This enables readers of the annotation to - * distinguish between true, false, and undefined values. + * Marker value used as a default for annotation properties of type + * {@code String}. */ - interface Boolean { - } + public static final String STRING = "io.fabric8.generator.annotation.JSONSchema.UNSET"; /** - * Marker class used as a default for annotation properties of type - * {@code Class}. + * Marker value used as a default for annotation properties of type + * {@code double}. */ - public static final class Undefined implements Boolean { - /** - * Marker value used as a default for annotation properties of type - * {@code String}. - */ - public static final String STRING = "io.fabric8.generator.annotation.JSONSchema.UNSET"; - - /** - * Marker value used as a default for annotation properties of type - * {@code double}. - */ - public static final double DOUBLE = Double.POSITIVE_INFINITY; - - /** - * Marker value used as a default for annotation properties of type - * {@code long}. Same binary value used for Double.POSITIVE_INFINITY. - */ - public static final long LONG = 0x7ff0000000000000L; - - private Undefined() { - } - } + public static final double DOUBLE = Double.POSITIVE_INFINITY; /** - * Marker class to indicate that a boolean {@code true} schema should be used. - * Additionally, this class is used to set a value of {@code true} for - * properties that allow true, false, or undefined boolean values. + * Marker value used as a default for annotation properties of type + * {@code long}. Same binary value used for Double.POSITIVE_INFINITY. */ - public static final class True implements Boolean { - private True() { - } - } + public static final long LONG = 0x7ff0000000000000L; - /** - * Marker class to indicate that a boolean {@code false} schema should be used. - * Additionally, this class is used to set a value of {@code false} for - * properties that allow true, false, or undefined boolean values. - */ - public static final class False implements Boolean { - private False() { - } + private Undefined() { } + } + + /** + * Marker class to indicate that a boolean {@code true} schema should be used. + * Additionally, this class is used to set a value of {@code true} for + * properties that allow true, false, or undefined boolean values. + */ + public static final class True implements Boolean { + private True() { + } + } + + /** + * Marker class to indicate that a boolean {@code false} schema should be used. + * Additionally, this class is used to set a value of {@code false} for + * properties that allow true, false, or undefined boolean values. + */ + public static final class False implements Boolean { + private False() { + } + } - @Target({}) - @Retention(RetentionPolicy.RUNTIME) - public @interface Map { - String name(); + @Target({}) + @Retention(RetentionPolicy.RUNTIME) + public @interface Map { + String name(); - Class value(); - } + Class value(); + } - @Target({}) - @Retention(RetentionPolicy.RUNTIME) - public @interface Dependency { - String[] properties() default {}; + @Target({}) + @Retention(RetentionPolicy.RUNTIME) + public @interface Dependency { + String[] properties() default {}; - Class schema() default Undefined.class; - } + Class schema() default Undefined.class; + } - @Target({}) - @Retention(RetentionPolicy.RUNTIME) - public @interface DependencyMap { - String name(); + @Target({}) + @Retention(RetentionPolicy.RUNTIME) + public @interface DependencyMap { + String name(); - Dependency value(); - } + Dependency value(); + } - @Target({}) - @Retention(RetentionPolicy.RUNTIME) - public @interface ExternalDocumentation { - String description() default Undefined.STRING; + @Target({}) + @Retention(RetentionPolicy.RUNTIME) + public @interface ExternalDocumentation { + String description() default Undefined.STRING; - String url() default Undefined.STRING; - } + String url(); + } - /** - * The implementation class allows for an additional type to be scanned as the - * basis for this schema. After scanning the implementation (if specified), the - * remaining properties will be set from this annotation, possibly overriding - * those determine by scanning the implementation class. - */ - Class implementation() default Undefined.class; + /** + * The implementation class allows for an additional type to be scanned as the + * basis for this schema. After scanning the implementation (if specified), the + * remaining properties will be set from this annotation, possibly overriding + * those determine by scanning the implementation class. + */ + Class implementation() default Undefined.class; - String $ref() default Undefined.STRING; // NOSONAR + String $ref() default Undefined.STRING; // NOSONAR - String $schema() default Undefined.STRING; // NOSONAR + String $schema() default Undefined.STRING; // NOSONAR - Class additionalItems() default Undefined.class; + Class additionalItems() default Undefined.class; - Class additionalProperties() default Undefined.class; + Class additionalProperties() default Undefined.class; - Class[] allOf() default {}; + Class[] allOf() default {}; - Class[] anyOf() default {}; + Class[] anyOf() default {}; - String defaultValue() default Undefined.STRING; + String defaultValue() default Undefined.STRING; - Map[] definitions() default {}; + Map[] definitions() default {}; - DependencyMap[] dependencies() default {}; + DependencyMap[] dependencies() default {}; - String description() default Undefined.STRING; + String description() default Undefined.STRING; - /* - * Parsed into com.fasterxml.jackson.databind.JsonNode depending on `type` - */ - String[] enumeration() default {}; + /* + * Parsed into com.fasterxml.jackson.databind.JsonNode depending on `type` + */ + String[] enumeration() default {}; - /* - * Parsed into com.fasterxml.jackson.databind.JsonNode depending on `type` - */ - String example() default Undefined.STRING; + /* + * Parsed into com.fasterxml.jackson.databind.JsonNode depending on `type` + */ + String example() default Undefined.STRING; - Class exclusiveMaximum() default Undefined.class; + Class exclusiveMaximum() default Undefined.class; - Class exclusiveMinimum() default Undefined.class; + Class exclusiveMinimum() default Undefined.class; - ExternalDocumentation externalDocs() default @ExternalDocumentation; + ExternalDocumentation externalDocs() default @ExternalDocumentation(url = Undefined.STRING); - String format() default Undefined.STRING; + String format() default Undefined.STRING; - String id() default Undefined.STRING; + String id() default Undefined.STRING; - Class[] items() default {}; + Class[] items() default {}; - long maxItems() default Undefined.LONG; + long maxItems() default Undefined.LONG; - long maxLength() default Undefined.LONG; + long maxLength() default Undefined.LONG; - long maxProperties() default Undefined.LONG; + long maxProperties() default Undefined.LONG; - double maximum() default Undefined.DOUBLE; + double maximum() default Undefined.DOUBLE; - long minItems() default Undefined.LONG; + long minItems() default Undefined.LONG; - long minLength() default Undefined.LONG; + long minLength() default Undefined.LONG; - long minProperties() default Undefined.LONG; + long minProperties() default Undefined.LONG; - double minimum() default Undefined.DOUBLE; + double minimum() default Undefined.DOUBLE; - double multipleOf() default Undefined.DOUBLE; + double multipleOf() default Undefined.DOUBLE; - Class not() default Undefined.class; + Class not() default Undefined.class; - Class nullable() default Undefined.class; + Class nullable() default Undefined.class; - Class[] oneOf() default {}; + Class[] oneOf() default {}; - String pattern() default Undefined.STRING; + String pattern() default Undefined.STRING; - Map[] patternProperties() default {}; + Map[] patternProperties() default {}; - Map[] properties() default {}; + Map[] properties() default {}; - String[] required() default {}; + String[] required() default {}; - String title() default Undefined.STRING; + String title() default Undefined.STRING; - String type() default Undefined.STRING; + String type() default Undefined.STRING; - Class uniqueItems() default Undefined.class; + Class uniqueItems() default Undefined.class; - Class xKubernetesEmbeddedResource() default Undefined.class; + Class xKubernetesEmbeddedResource() default Undefined.class; - Class xKubernetesIntOrString() default Undefined.class; + Class xKubernetesIntOrString() default Undefined.class; - String[] xKubernetesListMapKeys() default {}; + String[] xKubernetesListMapKeys() default {}; - String xKubernetesListType() default Undefined.STRING; + String xKubernetesListType() default Undefined.STRING; - String xKubernetesMapType() default Undefined.STRING; + String xKubernetesMapType() default Undefined.STRING; - Class xKubernetesPreserveUnknownFields() default Undefined.class; + Class xKubernetesPreserveUnknownFields() default Undefined.class; - ValidationRule[] xKubernetesValidations() default {}; -} \ No newline at end of file + ValidationRule[] xKubernetesValidations() default {}; +} From 80699112ff27998baaca3109ffd7b0fa9d9b83fc Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Fri, 11 Apr 2025 10:02:39 -0400 Subject: [PATCH 3/6] Add/fix license headers Signed-off-by: Michael Edgar --- .../crdv2/example/jsonschema/JsonSchemaAnno.java | 15 +++++++++++++++ .../example/jsonschema/JsonSchemaAnnoSpec.java | 15 +++++++++++++++ .../example/jsonschema/JsonSchemaAnnoStatus.java | 15 +++++++++++++++ .../generator/v1/JsonSchemaAnnotationTest.java | 15 +++++++++++++++ .../fabric8/generator/annotation/JSONSchema.java | 2 +- 5 files changed, 61 insertions(+), 1 deletion(-) diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnno.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnno.java index f498d759059..8024403f18e 100644 --- a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnno.java +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnno.java @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.crdv2.example.jsonschema; import io.fabric8.kubernetes.client.CustomResource; diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java index dd307fb7189..087274d8f9e 100644 --- a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.crdv2.example.jsonschema; import io.fabric8.generator.annotation.JSONSchema; diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoStatus.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoStatus.java index be1ef9ac3d8..f827016b09f 100644 --- a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoStatus.java +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoStatus.java @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.crdv2.example.jsonschema; import lombok.Data; diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java index bcc4f274197..6b9b265a87e 100644 --- a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.crdv2.generator.v1; import com.fasterxml.jackson.databind.node.JsonNodeFactory; diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java index 53a6356d428..8df15ec0c1f 100644 --- a/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java +++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Red Hat, Inc. + * Copyright (C) 2015 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 7860628467c816e9d8a9f7ef712ec0fc3d2839e3 Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Mon, 14 Apr 2025 07:44:45 -0400 Subject: [PATCH 4/6] Add `structural` support, differentiate between undefined/suppressed Signed-off-by: Michael Edgar --- CHANGELOG.md | 7 +- .../crdv2/generator/AbstractJsonSchema.java | 133 +++++----- .../crdv2/generator/ResolvingContext.java | 6 +- .../crdv2/generator/v1/JsonSchema.java | 230 ++++++++++++------ .../jsonschema/JsonSchemaAnnoSpec.java | 74 +++++- .../v1/JsonSchemaAnnotationTest.java | 97 +++++++- .../generator/annotation/JSONSchema.java | 157 +++++++++++- 7 files changed, 538 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd513eb618..c4c96211c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java index 935cb2ce4d9..a41db2da26a 100644 --- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java +++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java @@ -74,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; @@ -84,6 +83,7 @@ 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; @@ -170,7 +170,7 @@ private T resolveRoot(Class definition) { private T mapAnnotation(A annotation, Function mapper) { if (annotation != null) { - return mapper.apply(annotation); + return mapper.apply(annotation); } return null; } @@ -419,8 +419,8 @@ private Optional findMaxInSizeAnnotation(BeanProperty beanProperty) { private T resolveObject(LinkedHashMap visited, InternalSchemaSwaps schemaSwaps, JsonSchema jacksonSchema, String... ignore) { - Set ignores = ignore.length > 0 ? new LinkedHashSet<>(Arrays.asList(ignore)) : Collections.emptySet(); + Set ignores = Set.of(ignore); schemaSwaps = schemaSwaps.branchAnnotations(); final InternalSchemaSwaps swaps = schemaSwaps; @@ -434,8 +434,11 @@ private T resolveObject(LinkedHashMap visited, InternalSchemaSwa Class rawClass = gos.javaType.getRawClass(); collectDependentClasses(rawClass); - JSONSchema schemaAnnotation = resolvingContext.ignoreJSONSchemaAnnotation ? null : rawClass.getDeclaredAnnotation(JSONSchema.class); - T classSchema = mapAnnotation(schemaAnnotation, schema -> fromAnnotation(rawClass, true, schema)); + T classSchema = resolveSchemaAnnotation( + rawClass.getDeclaredAnnotation(JSONSchema.class), + rawClass, + true, + resolvingContext.ignoreJSONSchemaAnnotation); if (classSchema != null) { return classSchema; @@ -451,17 +454,16 @@ private T resolveObject(LinkedHashMap visited, InternalSchemaSwa List required = new ArrayList<>(); final T objectSchema = singleProperty("object"); - for (Map.Entry property : new TreeMap<>(gos.getProperties()).entrySet()) { + for (Map.Entry property : visibleProperties(gos.getProperties(), ignores).entrySet()) { String name = property.getKey(); - if (ignores.contains(name)) { - continue; - } BeanProperty beanProperty = gos.beanProperties.get(property.getKey()); Utils.checkNotNull(beanProperty, "CRD generation works only with bean properties"); - Class propRawClass = beanProperty.getType().getRawClass(); - JSONSchema propSchemaAnnotation = beanProperty.getAnnotation(JSONSchema.class); - T propSchema = mapAnnotation(propSchemaAnnotation, schema -> fromAnnotation(propRawClass, false, schema)); + T propSchema = resolveSchemaAnnotation( + beanProperty.getAnnotation(JSONSchema.class), + beanProperty.getType().getRawClass(), + false, + false); if (propSchema != null) { addProperty(name, objectSchema, propSchema); @@ -525,6 +527,20 @@ private T resolveObject(LinkedHashMap 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 visibleProperties(Map properties, Set 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); @@ -706,77 +722,68 @@ private Set findIgnoredEnumConstants(JavaType type) { } protected T fromAnnotation(Class rawClass, boolean isTargetType, JSONSchema schema) { - T result = mapImplementation(schema.implementation(), isTargetType); + T result = mapImplementation(schema.implementation(), isTargetType); - if (result == null) { - result = singleProperty(mapDefined(schema.type())); - } + if (result == null) { + result = singleProperty(null); + } - setIfDefined(mapDefined(schema.defaultValue(), rawClass), result::setDefault); - setIfDefined(mapDefined(schema.description()), result::setDescription); - setIfDefined(mapBoolean(schema.exclusiveMaximum()), result::setExclusiveMaximum); - setIfDefined(mapBoolean(schema.exclusiveMinimum()), result::setExclusiveMinimum); - setIfDefined(mapDefined(schema.format()), result::setFormat); - setIfDefined(mapDefined(schema.maximum()), result::setMaximum); - setIfDefined(mapDefined(schema.maxItems()), result::setMaxItems); - setIfDefined(mapDefined(schema.maxLength()), result::setMaxLength); - setIfDefined(mapDefined(schema.maxProperties()), result::setMaxProperties); - setIfDefined(mapDefined(schema.minimum()), result::setMinimum); - setIfDefined(mapDefined(schema.minItems()), result::setMinItems); - setIfDefined(mapDefined(schema.minLength()), result::setMinLength); - setIfDefined(mapDefined(schema.minProperties()), result::setMinProperties); - setIfDefined(mapBoolean(schema.nullable()), result::setNullable); - setIfDefined(mapDefined(schema.pattern()), result::setPattern); - setIfDefined(mapDefined(schema.required()), result::setRequired); - setIfDefined(mapBoolean(schema.xKubernetesPreserveUnknownFields()), result::setXKubernetesPreserveUnknownFields); - return result; + 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

void setIfDefined(P value, Consumer

mutator) { - if (value != null) { - mutator.accept(value); - } + protected static void setIfDefined(A value, Function transformer, Consumer mutator) { + setIfDefined(value, () -> null, transformer, mutator); } - protected JsonNode mapDefined(String value, Class targetType) { - if ((value = mapDefined(value)) == null) { - return null; + protected static void setIfDefined(A value, Consumer mutator) { + setIfDefined(value, () -> null, Function.identity(), mutator); + } + + protected static void setIfDefined(A value, Supplier defaultValue, Function transformer, + Consumer 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> 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) { - if (value.isEmpty()) { - LOGGER.warn("Cannot parse value '{}' from JSONSchema annotation as valid YAML or JSON, no value will be used.", value); - return null; - } throw new IllegalArgumentException("Cannot parse value '" + value + "' as valid YAML or JSON.", e); } } - protected static String mapDefined(String value) { - return JSONSchema.Undefined.STRING.equals(value) ? null : value; - } - - protected static List mapDefined(String[] values) { - return values.length == 0 ? null : List.of(values); - } - - protected static Double mapDefined(double value) { - return JSONSchema.Undefined.DOUBLE == value ? null : value; + protected List parseJson(String[] values, Class targetType) { + return Arrays.stream(values).map(value -> parseJson(value, targetType)).collect(Collectors.toList()); } - protected static Long mapDefined(long value) { - return JSONSchema.Undefined.LONG == value ? null : value; - } - - protected static Boolean mapBoolean(Class value) { - if (value == JSONSchema.Undefined.class) { - return null; // NOSONAR - } + protected Boolean mapBoolean(Class value) { return value == JSONSchema.True.class ? Boolean.TRUE : Boolean.FALSE; } diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/ResolvingContext.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/ResolvingContext.java index 5ae465c4c3d..15fea9bcae2 100644 --- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/ResolvingContext.java +++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/ResolvingContext.java @@ -113,11 +113,13 @@ public static ResolvingContext defaultResolvingContext(boolean implicitPreserveU } public ResolvingContext forkContext() { - return new ResolvingContext(objectMapper, kubernetesSerialization, uriToJacksonSchema, implicitPreserveUnknownFields, ignoreJSONSchemaAnnotation); + return new ResolvingContext(objectMapper, kubernetesSerialization, uriToJacksonSchema, implicitPreserveUnknownFields, + ignoreJSONSchemaAnnotation); } public ResolvingContext forkContext(boolean ignoreJSONSchemaAnnotation) { - return new ResolvingContext(objectMapper, kubernetesSerialization, uriToJacksonSchema, implicitPreserveUnknownFields, ignoreJSONSchemaAnnotation); + return new ResolvingContext(objectMapper, kubernetesSerialization, uriToJacksonSchema, implicitPreserveUnknownFields, + ignoreJSONSchemaAnnotation); } public ResolvingContext(ObjectMapper mapper, KubernetesSerialization kubernetesSerialization, diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java index a27e9f6566d..43ccebddc22 100644 --- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java +++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/v1/JsonSchema.java @@ -24,17 +24,22 @@ import io.fabric8.crdv2.generator.v1.JsonSchema.V1ValidationRule; import io.fabric8.generator.annotation.JSONSchema; import io.fabric8.kubernetes.api.model.apiextensions.v1.ExternalDocumentation; -import io.fabric8.kubernetes.api.model.apiextensions.v1.ExternalDocumentationBuilder; import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaPropsBuilder; import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaPropsOrArray; import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaPropsOrBool; import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaPropsOrStringArray; import io.fabric8.kubernetes.api.model.apiextensions.v1.ValidationRule; +import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -117,57 +122,99 @@ protected V1JSONSchemaProps raw() { @Override protected V1JSONSchemaProps fromAnnotation(Class rawClass, boolean isTargetType, JSONSchema schema) { - V1JSONSchemaProps result = super.fromAnnotation(rawClass, isTargetType, schema); - // maybe override the type if it was determined by reading the optional `implementation` - setIfDefined(mapDefined(schema.type()), result::setType); - setIfDefined(mapDefined(schema.$ref()), result::set$ref); - setIfDefined(mapDefined(schema.$schema()), result::set$schema); - setIfDefined(mapSchemaOrBool(schema.additionalItems()), result::setAdditionalItems); - setIfDefined(mapSchemaOrBool(schema.additionalProperties()), result::setAdditionalProperties); - setIfDefined(mapSchemaList(schema.allOf()), result::setAllOf); - setIfDefined(mapSchemaList(schema.anyOf()), result::setAnyOf); - setIfDefined(mapSchemaMap(schema.definitions()), result::setDefinitions); - setIfDefined(mapDependencies(schema.dependencies()), result::setDependencies); - setIfDefined(mapEnumeration(schema.enumeration(), rawClass), result::setEnum); - setIfDefined(mapDefined(schema.example(), rawClass), result::setExample); - setIfDefined(mapExternalDocs(schema.externalDocs()), result::setExternalDocs); - setIfDefined(mapDefined(schema.id()), result::setId); - setIfDefined(mapSchemaOrArray(schema.items()), result::setItems); - setIfDefined(mapDefined(schema.multipleOf()), result::setMultipleOf); - setIfDefined(mapSchema(schema.not()), result::setNot); - setIfDefined(mapSchemaList(schema.oneOf()), result::setOneOf); - setIfDefined(mapSchemaMap(schema.patternProperties()), result::setPatternProperties); - setIfDefined(mapSchemaMap(schema.properties()), result::setProperties); - setIfDefined(mapDefined(schema.title()), result::setTitle); - setIfDefined(mapBoolean(schema.uniqueItems()), result::setUniqueItems); - setIfDefined(mapBoolean(schema.xKubernetesEmbeddedResource()), result::setXKubernetesEmbeddedResource); - setIfDefined(mapBoolean(schema.xKubernetesIntOrString()), result::setXKubernetesIntOrString); - setIfDefined(mapDefined(schema.xKubernetesListMapKeys()), result::setXKubernetesListMapKeys); - setIfDefined(mapDefined(schema.xKubernetesListType()), result::setXKubernetesListType); - setIfDefined(mapDefined(schema.xKubernetesMapType()), result::setXKubernetesMapType); - setIfDefined(mapValidationRules(schema.xKubernetesValidations()), result::setXKubernetesValidations); - return result; + V1JSONSchemaProps result = super.fromAnnotation(rawClass, isTargetType, schema); + // maybe override the type if it was determined by reading the optional `implementation` + setIfDefined(schema.type(), result::setType); + setIfDefined(schema.$ref(), result::set$ref); + setIfDefined(schema.$schema(), result::set$schema); + setIfDefined(schema.additionalItems(), this::mapSchemaOrBool, result::setAdditionalItems); + setIfDefined(schema.additionalProperties(), this::mapSchemaOrBool, result::setAdditionalProperties); + setIfDefined(schema.definitions(), LinkedHashMap::new, this::mapSchemaMap, result::setDefinitions); + setIfDefined(schema.dependencies(), LinkedHashMap::new, this::mapDependencies, result::setDependencies); + setIfDefined(schema.enumeration(), ArrayList::new, v -> parseJson(v, rawClass), result::setEnum); + setIfDefined(schema.example(), v -> parseJson(v, rawClass), result::setExample); + setIfDefined(schema.externalDocs(), this::mapExternalDocs, result::setExternalDocs); + setIfDefined(schema.id(), result::setId); + setIfDefined(schema.multipleOf(), result::setMultipleOf); + setIfDefined(schema.patternProperties(), LinkedHashMap::new, this::mapSchemaMap, result::setPatternProperties); + setIfDefined(schema.title(), result::setTitle); + setIfDefined(schema.uniqueItems(), this::mapBoolean, result::setUniqueItems); + setIfDefined(schema.xKubernetesEmbeddedResource(), this::mapBoolean, result::setXKubernetesEmbeddedResource); + setIfDefined(schema.xKubernetesIntOrString(), this::mapBoolean, result::setXKubernetesIntOrString); + setIfDefined(schema.xKubernetesListMapKeys(), ArrayList::new, Arrays::asList, result::setXKubernetesListMapKeys); + setIfDefined(schema.xKubernetesListType(), result::setXKubernetesListType); + setIfDefined(schema.xKubernetesMapType(), result::setXKubernetesMapType); + setIfDefined(schema.xKubernetesValidations(), ArrayList::new, this::mapValidationRules, result::setXKubernetesValidations); + + if (schema.structural()) { + List allOf = new ArrayList<>(schema.allOf().length); + List anyOf = new ArrayList<>(schema.anyOf().length); + AtomicReference items = new AtomicReference<>(); + AtomicReference not = new AtomicReference<>(); + List oneOf = new ArrayList<>(schema.oneOf().length); + Map properties = new LinkedHashMap<>(); + + Consumer addProperties = s -> s.getProperties().forEach(properties::putIfAbsent); + /** + * Only sets the items if they have not yet been set. + */ + Consumer maybeSetItems = s -> items.compareAndSet(null, s.getItems()); + + setIfDefined(schema.items(), this::mapSchemaOrArray, items::set); + + setIfDefined(schema.allOf(), ArrayList::new, this::mapSchemaList, (List schemas) -> { + schemas.stream().map(this::mapJunctorSchema).forEach(allOf::add); + schemas.forEach(addProperties); + schemas.forEach(maybeSetItems); + }); + + setIfDefined(schema.anyOf(), ArrayList::new, this::mapSchemaList, (List schemas) -> { + schemas.stream().map(this::mapJunctorSchema).forEach(anyOf::add); + schemas.forEach(addProperties); + schemas.forEach(maybeSetItems); + }); + + setIfDefined(schema.not(), this::mapSchema, notSchema -> { + not.set(mapJunctorSchema(notSchema)); + addProperties.accept(notSchema); + maybeSetItems.accept(notSchema); + }); + + setIfDefined(schema.oneOf(), ArrayList::new, this::mapSchemaList, (List schemas) -> { + schemas.stream().map(this::mapJunctorSchema).forEach(oneOf::add); + schemas.forEach(addProperties); + schemas.forEach(maybeSetItems); + }); + + setIfDefined(schema.properties(), LinkedHashMap::new, this::mapSchemaMap, properties::putAll); + + result.setAllOf(allOf); + result.setAnyOf(anyOf); + result.setItems(items.get()); + result.setNot(not.get()); + result.setOneOf(oneOf); + result.setProperties(properties); + } else { + setIfDefined(schema.allOf(), ArrayList::new, this::mapSchemaList, result::setAllOf); + setIfDefined(schema.anyOf(), ArrayList::new, this::mapSchemaList, result::setAnyOf); + setIfDefined(schema.items(), this::mapSchemaOrArray, result::setItems); + setIfDefined(schema.not(), this::mapSchema, result::setNot); + setIfDefined(schema.oneOf(), ArrayList::new, this::mapSchemaList, result::setOneOf); + setIfDefined(schema.properties(), LinkedHashMap::new, this::mapSchemaMap, result::setProperties); + } + + return result; } @Override protected V1JSONSchemaProps mapImplementation(Class value, boolean isTargetType) { - if (value == JSONSchema.Undefined.class) { + if (JSONSchema.Undefined.isUndefined(value)) { return null; // NOSONAR } return new JsonSchema(resolvingContext.forkContext(isTargetType), value).getSchema(); } - private JSONSchemaProps mapSchema(Class value) { - if (value == JSONSchema.Undefined.class) { - return null; // NOSONAR - } - return new JsonSchema(resolvingContext.forkContext(false), value).getSchema(); - } - private JSONSchemaPropsOrBool mapSchemaOrBool(Class value) { - if (value == JSONSchema.Undefined.class) { - return null; // NOSONAR - } JSONSchemaPropsOrBool result = new JSONSchemaPropsOrBool(); if (JSONSchema.Boolean.class.isAssignableFrom(value)) { @@ -181,66 +228,89 @@ private JSONSchemaPropsOrBool mapSchemaOrBool(Class value) { return result; } - private List mapEnumeration(String[] examples, Class targetType) { - if (examples.length != 0) { - return Arrays.stream(examples).map(ex -> mapDefined(ex, targetType)).collect(Collectors.toList()); + private JSONSchemaPropsOrArray mapSchemaOrArray(Class[] values) { + return mapSchemaOrArray(mapSchemaList(values)); + } + + private JSONSchemaPropsOrArray mapSchemaOrArray(List schemas) { + JSONSchemaPropsOrArray result = new JSONSchemaPropsOrArray(); + + if (schemas.size() == 1) { + result.setSchema(schemas.get(0)); + } else { + result.setJSONSchemas(schemas); } - return null; // NOSONAR + + return result; } - private JSONSchemaPropsOrArray mapSchemaOrArray(Class[] values) { - return Optional.ofNullable(mapSchemaList(values)) - .map(schemas -> { - JSONSchemaPropsOrArray result = new JSONSchemaPropsOrArray(); - if (schemas.size() == 1) { - result.setSchema(schemas.get(0)); - } else { - result.setJSONSchemas(schemas); - } - return result; - }) - .orElse(null); + private JSONSchemaProps mapSchema(Class value) { + return new JsonSchema(resolvingContext.forkContext(false), value).getSchema(); } private List mapSchemaList(Class[] values) { - if (values.length == 0) { - return null; // NOSONAR + return Arrays.stream(values).map(this::mapSchema).collect(Collectors.toList()); + } + + /** + * Suppress attributes disallowed within a logical junctor (allOf, anyOf, oneOf, not). This functionality + * is in support of rule #3 from "Specifying a structural schema". + * + * @see https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#specifying-a-structural-schema + */ + private JSONSchemaProps mapJunctorSchema(JSONSchemaProps schema) { + JSONSchemaPropsBuilder builder = schema.edit() + .withDescription(null) + .withDefault(null) + .withAdditionalProperties(null) + .withNullable(null) + .withItems(Optional.ofNullable(schema.getItems()) + .map(items -> Stream + .concat( + Optional.ofNullable(items.getJSONSchemas()) + .map(List::stream) + .orElseGet(Stream::empty), + Stream.of(items.getSchema())) + .filter(Objects::nonNull) + .map(this::mapJunctorSchema) + .collect(Collectors.toList())) + .map(this::mapSchemaOrArray) + .orElse(null)) + .withProperties(schema.getProperties().entrySet() + .stream() + // Recursively remove property attributes + .map(e -> Map.entry(e.getKey(), mapJunctorSchema(e.getValue()))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + + if (!Boolean.TRUE.equals(schema.getXKubernetesIntOrString())) { + builder.withType(null); } - return Arrays.stream(values) - .map(this::mapSchema) - .collect(Collectors.toList()); + return builder.build(); } private Map mapSchemaMap(JSONSchema.Map[] entries) { - if (entries.length == 0) { - return null; // NOSONAR - } - return Arrays.stream(entries) .map(e -> Map.entry(e.name(), mapSchema(e.value()))) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } private Map mapDependencies(JSONSchema.DependencyMap[] entries) { - if (entries.length == 0) { - return null; // NOSONAR - } - return Arrays.stream(entries) - .map(e -> Map.entry(e.name(), new JSONSchemaPropsOrStringArray(mapDefined(e.value().properties()), mapSchema(e.value().schema())))) + .map(e -> { + JSONSchemaPropsOrStringArray result = new JSONSchemaPropsOrStringArray(); + setIfDefined(e.value().properties(), ArrayList::new, Arrays::asList, result::setProperty); + setIfDefined(e.value().schema(), this::mapSchema, result::setSchema); + return Map.entry(e.name(), result); + }) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } private ExternalDocumentation mapExternalDocs(JSONSchema.ExternalDocumentation externalDocs) { - if (Stream.of(externalDocs.description(), externalDocs.url()).allMatch(JSONSchema.Undefined.STRING::equals)) { - return null; - } - - return new ExternalDocumentationBuilder() - .withDescription(mapDefined(externalDocs.description())) - .withUrl(mapDefined(externalDocs.url())) - .build(); + ExternalDocumentation result = new ExternalDocumentation(); + setIfDefined(externalDocs.description(), result::setDescription); + setIfDefined(externalDocs.url(), result::setUrl); + return result; } private List mapValidationRules(io.fabric8.generator.annotation.ValidationRule[] values) { diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java index 087274d8f9e..f73dabb16bc 100644 --- a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/jsonschema/JsonSchemaAnnoSpec.java @@ -17,6 +17,7 @@ import io.fabric8.generator.annotation.JSONSchema; import io.fabric8.generator.annotation.JSONSchema.ExternalDocumentation; +import io.fabric8.generator.annotation.ValidationRule; import lombok.Data; import java.util.List; @@ -91,7 +92,7 @@ static class ObjectEnumerationSchema { @JSONSchema(type = "object", implementation = ObjectEnumerationSchema.class, defaultValue = "{ \"field1\": \"allowedValue1\", \"field2\": 1 }", enumeration = { "{ \"field1\": \"allowedValue1\", \"field2\": 1 }", "{ \"field1\": \"allowedValue2\", \"field2\": 2 }", - }) + }, example = "{ \"field1\": \"allowedValue2\", \"field2\": 2 }") private OverriddenPropertiesSchema objectEnumeration; /* *********************************************************************** */ @@ -114,4 +115,75 @@ static class DependentPropertiesSchema { private DependentPropertiesSchema dependentProperties; + /* *********************************************************************** */ + + @Data + @JSONSchema(implementation = SuppressionSchema.class, additionalProperties = String.class, minProperties = 1, maxProperties = 10, example = "{ \"field2\": 42 }", xKubernetesValidations = @ValidationRule("some rule")) + static class SuppressionSchema { + String field1; + Integer field2; + @JSONSchema(minItems = 3) + List field3; + } + + @JSONSchema(implementation = SuppressionSchema.class, additionalProperties = JSONSchema.Suppressed.class, minProperties = JSONSchema.Suppressed.LONG, example = JSONSchema.Suppressed.STRING, xKubernetesValidations = {}) + private SuppressionSchema suppression; + + /* *********************************************************************** */ + + @Data + static class StructuralSchema1 { + String string1; + } + + @Data + static class StructuralSchema2 { + String string2; + } + + @Data + static class StructuralSchema3 { + String string3; + StructuralSchema1 structural3; + } + + @Data + static class StructuralSchema4 { + List string4; + } + + @Data + static class StructuralSchema5 { + String string5; + } + + @Data + static class StructuralSchema6 { + String string6; + } + + @Data + static class StructuralSchema7 { + String string7; + StructuralSchema8 intOrString7; + } + + @Data + @JSONSchema(xKubernetesIntOrString = JSONSchema.True.class, anyOf = { Integer.class, String.class }) + static class StructuralSchema8 { + String string8; + Integer integer8; + } + + @Data + @JSONSchema(structural = true, allOf = { StructuralSchema4.class, StructuralSchema5.class }, anyOf = { + StructuralSchema1.class, + StructuralSchema3.class }, not = StructuralSchema2.class, oneOf = { StructuralSchema6.class, StructuralSchema7.class }) + static class StructuralSchema { + String string; + Integer integer; + } + + private StructuralSchema structural; + } diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java index 6b9b265a87e..48c66e5f418 100644 --- a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaAnnotationTest.java @@ -23,8 +23,16 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -75,7 +83,8 @@ void testLaxArrayItemSchema() { assertNotNull(target.getItems()); List itemsSchemas = target.getItems().getJSONSchemas(); assertEquals(2, itemsSchemas.size()); - assertTrue(itemsSchemas.stream().allMatch(s -> s.getProperties().keySet().containsAll(List.of("field1", "field2")))); + assertEquals(Set.of("field1", "field2"), itemsSchemas.get(0).getProperties().keySet()); + assertEquals(Set.of("field1", "field2"), itemsSchemas.get(1).getProperties().keySet()); assertEquals(Boolean.TRUE, itemsSchemas.get(0).getAdditionalProperties().getAllows()); assertNull(itemsSchemas.get(0).getAdditionalProperties().getSchema()); @@ -92,11 +101,12 @@ void testOverriddenProperties() { } @Test - void testObjectEnumerationWithDefault() { + void testObjectEnumerationWithDefaultAndExample() { JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("objectEnumeration"); ObjectNode expected1 = JsonNodeFactory.instance.objectNode().put("field1", "allowedValue1").put("field2", 1); ObjectNode expected2 = JsonNodeFactory.instance.objectNode().put("field1", "allowedValue2").put("field2", 2); assertEquals(expected1, target.getDefault()); + assertEquals(expected2, target.getExample()); assertEquals(List.of(expected1, expected2), target.getEnum()); } @@ -107,7 +117,7 @@ void testDependencies() { assertNull(field1Deps.getSchema()); assertEquals(List.of("field2"), field1Deps.getProperty()); JSONSchemaPropsOrStringArray field2Deps = target.getDependencies().get("field2"); - assertNull(field2Deps.getProperty()); + assertEquals(Collections.emptyList(), field2Deps.getProperty()); assertEquals("integer", field2Deps.getSchema().getType()); assertEquals(Boolean.TRUE, field2Deps.getSchema().getNullable()); assertEquals(Double.valueOf(0), field2Deps.getSchema().getMinimum()); @@ -116,5 +126,84 @@ void testDependencies() { assertEquals(Boolean.TRUE, field2Deps.getSchema().getExclusiveMaximum()); } -} + static class RecursiveTypeChecker { + final AtomicInteger count = new AtomicInteger(0); + final BiConsumer assertion; + + RecursiveTypeChecker(BiConsumer assertion) { + this.assertion = assertion; + } + + Stream itemsToSchemas(JSONSchemaProps schema) { + return Optional.ofNullable(schema.getItems()) + .map(items -> Stream + .concat( + Optional.ofNullable(items.getJSONSchemas()) + .map(List::stream) + .orElseGet(Stream::empty), + Stream.of(items.getSchema())) + .filter(Objects::nonNull)) + .orElseGet(Stream::empty); + } + + void accept(JSONSchemaProps schema, boolean checkProperties) { + List directSchemas = Stream.of( + schema.getAllOf().stream(), + schema.getAnyOf().stream(), + Stream.of(schema.getNot()), + schema.getOneOf().stream(), + checkProperties ? itemsToSchemas(schema) : Stream. empty(), + checkProperties ? schema.getProperties().values().stream() : Stream. empty()) + .flatMap(Function.identity()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + for (JSONSchemaProps s : directSchemas) { + assertion.accept(s, schema); + accept(s, true); + count.incrementAndGet(); + } + } + } + + @Test + void testStructuralSchemaJunctorRemovedTypes() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("structural"); + RecursiveTypeChecker checker = new RecursiveTypeChecker((childSchema, parentSchema) -> { + // Type is allowed with a schema having `x-kubernetes-int-or-string: true` + if (!Boolean.TRUE.equals(parentSchema.getXKubernetesIntOrString())) { + assertNull(childSchema.getType()); + } + }); + checker.accept(target, false); + assertEquals(20, checker.count.get()); + } + + @Test + void testStructuralSchemaJunctorPropertiesCopied() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("structural"); + assertEquals(9, target.getProperties().size()); + assertEquals("string", target.getProperties().get("string1").getType()); + assertEquals("string", target.getProperties().get("string2").getType()); + assertEquals("string", target.getProperties().get("string3").getType()); + assertEquals("object", target.getProperties().get("structural3").getType()); + assertEquals("array", target.getProperties().get("string4").getType()); + assertEquals("string", target.getProperties().get("string4").getItems().getSchema().getType()); + assertEquals("string", target.getProperties().get("string5").getType()); + assertEquals("string", target.getProperties().get("string6").getType()); + assertEquals("string", target.getProperties().get("string7").getType()); + } + + @Test + void testStructuralSchemaJunctorIntOrString() { + JSONSchemaProps target = schema.getProperties().get("spec").getProperties().get("structural"); + assertEquals(2, target.getOneOf().size()); + JSONSchemaProps intOrStringSchema = target.getOneOf().get(1).getProperties().get("intOrString7"); + assertNull(intOrStringSchema.getType()); + assertEquals(Boolean.TRUE, intOrStringSchema.getXKubernetesIntOrString()); + assertEquals(2, intOrStringSchema.getAnyOf().size()); + assertEquals("integer", intOrStringSchema.getAnyOf().get(0).getType()); + assertEquals("string", intOrStringSchema.getAnyOf().get(1).getType()); + } +} diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java index 8df15ec0c1f..f5055083edb 100644 --- a/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java +++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java @@ -19,6 +19,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.lang.reflect.Array; @Target({ ElementType.TYPE, ElementType.FIELD, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @@ -41,7 +42,7 @@ public static final class Undefined implements Boolean { * Marker value used as a default for annotation properties of type * {@code String}. */ - public static final String STRING = "io.fabric8.generator.annotation.JSONSchema.UNSET"; + public static final String STRING = "io.fabric8.generator.annotation.JSONSchema.Undefined"; /** * Marker value used as a default for annotation properties of type @@ -57,6 +58,105 @@ public static final class Undefined implements Boolean { private Undefined() { } + + public static boolean isUndefined(Object value) { + if (value == Undefined.class) { + return true; + } + if (STRING.equals(value)) { + return true; + } + if (value instanceof Double && (Double) value == DOUBLE) { + return true; + } + if (value instanceof Long && (Long) value == LONG) { + return true; + } + if (value instanceof Map) { + return isUndefined(((Map) value).value()); + } + if (value instanceof DependencyMap) { + return isUndefined(((DependencyMap) value).value()); + } + if (value instanceof Dependency) { + return isUndefined(((Dependency) value).schema()); + } + if (value instanceof ExternalDocumentation) { + return isUndefined(((ExternalDocumentation) value).url()); + } + if (value instanceof ValidationRule) { + return isUndefined(((ValidationRule) value).value()); + } + return isUndefinedArray(value); + } + + private static boolean isUndefinedArray(Object value) { + if (value.getClass().isArray() && Array.getLength(value) == 1) { + return isUndefined(Array.get(value, 0)); + } + return false; + } + } + + /** + * Marker class used to suppress annotation properties of type {@code Class} that may have been + * set by the use of an `implementation` class. + */ + public static final class Suppressed implements Boolean { + /** + * Marker value used to suppress annotation properties of type + * {@code String} that may have been set by an {@code implementation} class. + */ + public static final String STRING = "io.fabric8.generator.annotation.JSONSchema.Suppressed"; + + /** + * Marker value used to suppress annotation properties of type + * {@code double} that may have been set by an {@code implementation} class. + */ + public static final double DOUBLE = Double.NEGATIVE_INFINITY; + + /** + * Marker value used to suppress annotation properties of type + * {@code long} that may have been set by an {@code implementation} class. + */ + public static final long LONG = 0xfff0000000000000L; + + private Suppressed() { + } + + public static boolean isSuppressed(Object value) { + if (value == Suppressed.class) { + return true; + } + if (STRING.equals(value)) { + return true; + } + if (value instanceof Double && (Double) value == DOUBLE) { + return true; + } + if (value instanceof Long && (Long) value == LONG) { + return true; + } + if (value instanceof ExternalDocumentation) { + return isSuppressed(((ExternalDocumentation) value).url()); + } + return isSuppressedArray(value); + } + + private static boolean isSuppressedArray(Object value) { + if (value.getClass().isArray()) { + switch (Array.getLength(value)) { + case 0: + // Any of the annotation arrays can be set to length zero to indicate suppression. + return true; + case 1: + return isSuppressed(Array.get(value, 0)); + default: + break; + } + } + return false; + } } /** @@ -119,6 +219,37 @@ private False() { */ Class implementation() default Undefined.class; + /** + * When true, this property indicates that the schema for this element is structural. The CRD + * generator will enhance the schema to conform to rules 2 and 3 of a Kubernetes structural schema. + *

+ * From the Kubernetes documentation, these rules are as follows (rules 1 and 4 are omitted since they are not relevant to the + * processing enabled by {@linkplain #structural}). + *

    + *
  1. for each field in an object and each item in an array which is specified within any of {@code allOf}, {@code anyOf}, + * {@code oneOf} or {@code not}, the schema also specifies the field/item outside of those logical junctors
  2. + *
  3. does not set {@code description}, {@code type}, {@code default}, {@code additionalProperties}, {@code nullable} within + * an {@code allOf}, {@code anyOf}, {@code oneOf} or {@code not}, with the exception of the two pattern for + * {@code x-kubernetes-int-or-string: true}
  4. + *
+ *

+ * This means that if this schema specifies classes within one or more of the logical junctors ({@code allOf}, {@code anyOf}, + * {@code oneOf} or {@code not}), the CRD generator will + * ensure that + *

    + *
  1. Each property/item of each entry within the logical junctors is also added to the properties of the structural schema. + * If a property name is duplicated, only the first occurrence will be used. If two or more schemas specify array + * {@code items}, only the first {@code items} will be used. Finally, if this schema itself specifies {@code items}, any + * {@code items} of the schemas within the logical junctors will not be used.
  2. + *
  3. The forbidden attributes of schemas within the logical junctors will be removed. These are {@code description}, + * {@code type}, {@code default}, {@code additionalProperties}, and {@code nullable}. Note that these attributes will be + * present in the schemas that are set in the properties in step 1.
  4. + *
+ * + * @see https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#specifying-a-structural-schema + */ + boolean structural() default false; + String $ref() default Undefined.STRING; // NOSONAR String $schema() default Undefined.STRING; // NOSONAR @@ -127,22 +258,22 @@ private False() { Class additionalProperties() default Undefined.class; - Class[] allOf() default {}; + Class[] allOf() default Undefined.class; - Class[] anyOf() default {}; + Class[] anyOf() default Undefined.class; String defaultValue() default Undefined.STRING; - Map[] definitions() default {}; + Map[] definitions() default @Map(name = Undefined.STRING, value = Undefined.class); - DependencyMap[] dependencies() default {}; + DependencyMap[] dependencies() default @DependencyMap(name = Undefined.STRING, value = @Dependency()); String description() default Undefined.STRING; /* * Parsed into com.fasterxml.jackson.databind.JsonNode depending on `type` */ - String[] enumeration() default {}; + String[] enumeration() default Undefined.STRING; /* * Parsed into com.fasterxml.jackson.databind.JsonNode depending on `type` @@ -159,7 +290,7 @@ private False() { String id() default Undefined.STRING; - Class[] items() default {}; + Class[] items() default Undefined.class; long maxItems() default Undefined.LONG; @@ -183,15 +314,15 @@ private False() { Class nullable() default Undefined.class; - Class[] oneOf() default {}; + Class[] oneOf() default Undefined.class; String pattern() default Undefined.STRING; - Map[] patternProperties() default {}; + Map[] patternProperties() default @Map(name = Undefined.STRING, value = Undefined.class); - Map[] properties() default {}; + Map[] properties() default @Map(name = Undefined.STRING, value = Undefined.class); - String[] required() default {}; + String[] required() default Undefined.STRING; String title() default Undefined.STRING; @@ -203,7 +334,7 @@ private False() { Class xKubernetesIntOrString() default Undefined.class; - String[] xKubernetesListMapKeys() default {}; + String[] xKubernetesListMapKeys() default Undefined.STRING; String xKubernetesListType() default Undefined.STRING; @@ -211,5 +342,5 @@ private False() { Class xKubernetesPreserveUnknownFields() default Undefined.class; - ValidationRule[] xKubernetesValidations() default {}; + ValidationRule[] xKubernetesValidations() default @ValidationRule(value = Undefined.STRING); } From eaee1ceedbda4df2e15ea6e9219895414b49e8dd Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Mon, 14 Apr 2025 12:30:38 -0400 Subject: [PATCH 5/6] fix: correct JavaDoc link Signed-off-by: Michael Edgar --- .../java/io/fabric8/generator/annotation/JSONSchema.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java index f5055083edb..47dcaa7cd23 100644 --- a/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java +++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/JSONSchema.java @@ -246,7 +246,10 @@ private False() { * present in the schemas that are set in the properties in step 1. * * - * @see https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#specifying-a-structural-schema + * @see
Extend + * the Kubernetes API with CustomResourceDefinitions: Specifying a structural schema + * */ boolean structural() default false; From bf8d66956e1f786b3b27fe118e17af97373af4f7 Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Mon, 14 Apr 2025 14:00:50 -0400 Subject: [PATCH 6/6] Remove unused method Signed-off-by: Michael Edgar --- .../io/fabric8/crdv2/generator/AbstractJsonSchema.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java index a41db2da26a..54a3f95af94 100644 --- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java +++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java @@ -167,14 +167,6 @@ private T resolveRoot(Class definition) { resolvingContext.objectMapper.getSerializationConfig().constructType(definition), schema, null); } - private T mapAnnotation(A annotation, - Function mapper) { - if (annotation != null) { - return mapper.apply(annotation); - } - return null; - } - /** * Walks up the class hierarchy to consume the repeating annotation */