From 32e49f2986b6ffdcae01d0a03349640cdc3ccce4 Mon Sep 17 00:00:00 2001 From: Adrian Suarez Date: Wed, 28 Aug 2024 20:28:47 -0400 Subject: [PATCH] CRD generator implementation that supports Jakarta and Swagger This change adds an alternative CRD generator implementation that is based on [victools/jsonschema-generator](https://github.com/victools/jsonschema-generator). The victools generator allows modules to be registered to customize schema generation, and there are modules that provide support for Jakarta Validation and Swagger annotations. These changes are being contributed into test scope as a prototype, since no effort has been made to integrate it with the existing CRD generator API. It is a totally separate implementation that is not at parity with the existing generator. The intent is that someone with more knowledge of the Fabric8 codebase can incorporate some aspects of it into the real product. --- crd-generator/api-v2/pom.xml | 41 +++++ .../alt/AdditionalPrinterColumn.java | 72 ++++++++ .../alt/ConfigurableCrdGenerator.java | 156 ++++++++++++++++++ .../generator/alt/CrdComplianceModule.java | 43 +++++ .../generator/alt/EnumDescriptionsModule.java | 63 +++++++ .../crdv2/generator/alt/Fabric8Module.java | 120 ++++++++++++++ .../crdv2/generator/alt/GeneratorUtils.java | 45 +++++ .../generator/alt/test/CompatibilityTest.java | 67 ++++++++ .../generator/alt/test/ExtraFeaturesTest.java | 79 +++++++++ .../alt/test/JakartaValidationTest.java | 106 ++++++++++++ .../crdv2/generator/alt/test/SwaggerTest.java | 95 +++++++++++ pom.xml | 3 + 12 files changed, 890 insertions(+) create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/AdditionalPrinterColumn.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/ConfigurableCrdGenerator.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/CrdComplianceModule.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/EnumDescriptionsModule.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/Fabric8Module.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/GeneratorUtils.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/CompatibilityTest.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/ExtraFeaturesTest.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/JakartaValidationTest.java create mode 100644 crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/SwaggerTest.java diff --git a/crd-generator/api-v2/pom.xml b/crd-generator/api-v2/pom.xml index e0bb7794270..2e19caa12ee 100644 --- a/crd-generator/api-v2/pom.xml +++ b/crd-generator/api-v2/pom.xml @@ -77,5 +77,46 @@ lombok test + + org.assertj + assertj-core + test + + + com.github.victools + jsonschema-generator + ${victools-jsonschema.version} + test + + + com.github.victools + jsonschema-module-jackson + ${victools-jsonschema.version} + test + + + com.github.victools + jsonschema-module-jakarta-validation + ${victools-jsonschema.version} + test + + + com.github.victools + jsonschema-module-swagger-2 + ${victools-jsonschema.version} + test + + + jakarta.validation + jakarta.validation-api + ${jakarta-validation.version} + test + + + io.swagger.core.v3 + swagger-annotations + ${swagger-annotations.version} + test + diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/AdditionalPrinterColumn.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/AdditionalPrinterColumn.java new file mode 100644 index 00000000000..570c16845e7 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/AdditionalPrinterColumn.java @@ -0,0 +1,72 @@ +package io.fabric8.crdv2.generator.alt; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation that allows additionalPrinterColumns entries to be created with arbitrary JSONPaths. + */ +@Repeatable(AdditionalPrinterColumn.List.class) +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface AdditionalPrinterColumn { + + String name(); + + String jsonPath(); + + AdditionalPrinterColumn.Type type() default Type.STRING; + + AdditionalPrinterColumn.Format format() default Format.NONE; + + String description() default ""; + + int priority() default 0; + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @interface List { + + AdditionalPrinterColumn[] value(); + } + + // https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#type + public static enum Type { + + STRING("string"), + INTEGER("integer"), + NUMBER("number"), + BOOLEAN("boolean"), + DATE("date"); + + public final String value; + + Type(String value) { + this.value = value; + } + } + + // https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#format + public static enum Format { + + NONE(""), + INT32("int32"), + INT64("int64"), + FLOAT("float"), + DOUBLE("double"), + BYTE("byte"), + BINARY("binary"), + DATE("date"), + DATE_TIME("date-time"), + PASSWORD("password"); + + public final String value; + + Format(String value) { + this.value = value; + } + } +} \ No newline at end of file diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/ConfigurableCrdGenerator.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/ConfigurableCrdGenerator.java new file mode 100644 index 00000000000..abfabc78d5a --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/ConfigurableCrdGenerator.java @@ -0,0 +1,156 @@ +package io.fabric8.crdv2.generator.alt; + +import static io.fabric8.crdv2.generator.alt.GeneratorUtils.convertValue; +import static io.fabric8.crdv2.generator.alt.GeneratorUtils.getPrinterColumns; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.victools.jsonschema.generator.Module; +import com.github.victools.jsonschema.generator.Option; +import com.github.victools.jsonschema.generator.OptionPreset; +import com.github.victools.jsonschema.generator.SchemaGenerator; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.generator.SchemaVersion; +import com.github.victools.jsonschema.module.jackson.JacksonModule; +import com.github.victools.jsonschema.module.jackson.JacksonOption; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.Plural; +import io.fabric8.kubernetes.model.annotation.Singular; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * Alternative CRD generator implementation that uses victools/jsonschema-generator and allows schema generation to be + * customized via modules. + *

+ * + * Support for Jakarta Validation API annotations can be enabled using victools/jsonschema-module-jakarta-validation. + *

+ * + * Support for Swagger annotations can be enabled using victools/jsonschema-module-swagger-2. + */ +public class ConfigurableCrdGenerator { + + private final List modules = new ArrayList<>(); + private SchemaGenerator schemaGenerator = null; + + public ConfigurableCrdGenerator register(Module module) { + if (schemaGenerator != null) { + throw new IllegalStateException("Already created schema generator"); + } + modules.add(module); + return this; + } + + protected SchemaGeneratorConfigBuilder baseConfig() { + return new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_6, OptionPreset.PLAIN_JSON) + .with(Option.ENUM_KEYWORD_FOR_SINGLE_VALUES) + .with(Option.INLINE_ALL_SCHEMAS) + .with(Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES) + .with(new JacksonModule( + JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY, + JacksonOption.RESPECT_JSONPROPERTY_ORDER)) + .with(new Fabric8Module()); + } + + protected synchronized SchemaGenerator schemaGenerator() { + if (schemaGenerator == null) { + SchemaGeneratorConfigBuilder configBuilder = baseConfig(); + for (Module module : modules) { + configBuilder = configBuilder.with(module); + } + configBuilder = configBuilder.with(new CrdComplianceModule()); + schemaGenerator = new SchemaGenerator(configBuilder.build()); + } + return schemaGenerator; + } + + public ObjectNode getSchema(Type type) { + ObjectNode schema = schemaGenerator().generateSchema(type); + schema.remove("$schema"); + return schema; + } + + public CustomResourceDefinition generateCrd(Class> crdClass) { + Type genericClass = crdClass.getGenericSuperclass(); + if (!(genericClass instanceof ParameterizedType)) { + throw new IllegalArgumentException(crdClass.getName() + " is not a parameterize type"); + } + Type[] typeParams = ((ParameterizedType) genericClass).getActualTypeArguments(); + if (typeParams.length != 2) { + throw new IllegalArgumentException( + "Unexpected number of type parameters for class " + crdClass.getName() + ": " + typeParams.length); + } + @SuppressWarnings("unchecked") + Class specClass = (Class) typeParams[0]; + @SuppressWarnings("unchecked") + Class statusClass = (Class) typeParams[1]; + return generateCrd(crdClass, specClass, statusClass); + } + + public CustomResourceDefinition generateCrd( + Class> crdClass, + Class specClass, Class statusClass) { + String kind = Optional.ofNullable(crdClass.getAnnotation(Kind.class)) + .map(ann -> ann.value()) + .orElseGet(crdClass::getSimpleName); + String singular = Optional.ofNullable(crdClass.getAnnotation(Singular.class)) + .map(ann -> ann.value()) + .orElseGet(() -> kind.toLowerCase(Locale.US)); + String plural = Optional.ofNullable(crdClass.getAnnotation(Plural.class)) + .map(ann -> ann.value()) + .orElseGet(() -> singular + "s"); + String group = Optional.ofNullable(crdClass.getAnnotation(Group.class)) + .map(ann -> ann.value()) + .orElse(crdClass.getPackage().getName()); + String version = Optional.ofNullable(crdClass.getAnnotation(Version.class)) + .map(ann -> ann.value()) + .orElse("v1beta1"); + String scope = Namespaced.class.isAssignableFrom(crdClass) ? "Namespaced" : "Cluster"; + JsonNode specSchema = getSchema(specClass); + JsonNode statusSchema = getSchema(statusClass); + return new CustomResourceDefinition().edit() + .withNewMetadata() + .withName(plural + "." + group) + .endMetadata() + .withNewSpec() + .withGroup(group) + .withNewNames() + .withKind(kind) + .withPlural(plural) + .withSingular(singular) + .endNames() + .withScope(scope) + .addNewVersion() + .withName(version) + .withAdditionalPrinterColumns(getPrinterColumns(crdClass)) + .withNewSchema() + .withNewOpenAPIV3Schema() + .addToProperties("spec", convertValue(specSchema, JSONSchemaProps.class)) + .addToProperties("status", convertValue(statusSchema, JSONSchemaProps.class)) + .withType("object") + .endOpenAPIV3Schema() + .endSchema() + .withServed() + .withStorage() + .withNewSubresources() + .withNewStatus() + .endStatus() + .endSubresources() + .endVersion() + .endSpec() + .build(); + } +} diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/CrdComplianceModule.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/CrdComplianceModule.java new file mode 100644 index 00000000000..d4ef1149f65 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/CrdComplianceModule.java @@ -0,0 +1,43 @@ +package io.fabric8.crdv2.generator.alt; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.victools.jsonschema.generator.Module; +import com.github.victools.jsonschema.generator.SchemaGenerationContext; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; + +/** + * Module that enforces compliance with JSONSchema dialect used by CRDs. This should be registered last so that it removes any + * restricted attributes added by other modules. + */ +public class CrdComplianceModule implements Module { + + // attributes not supported by CRDs according to Kubernetes documentation: + // https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation + public static final List RESTRICTED_ATTRIBUTES = Collections.unmodifiableList(Arrays.asList( + "definitions", + "dependencies", + "deprecated", + "discriminator", + "id", + "patternProperties", + "readOnly", + "writeOnly", + "xml", + "$ref", + "uniqueItems")); + + @Override + public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) { + builder.forTypesInGeneral().withTypeAttributeOverride(this::overrideAttributes); + builder.forFields().withInstanceAttributeOverride(this::overrideAttributes); + builder.forMethods().withInstanceAttributeOverride(this::overrideAttributes); + } + + protected void overrideAttributes(ObjectNode attributes, Object scope, SchemaGenerationContext context) { + RESTRICTED_ATTRIBUTES.forEach(attributes::remove); + } +} \ No newline at end of file diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/EnumDescriptionsModule.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/EnumDescriptionsModule.java new file mode 100644 index 00000000000..4ca5604ae6d --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/EnumDescriptionsModule.java @@ -0,0 +1,63 @@ +package io.fabric8.crdv2.generator.alt; + +import java.lang.reflect.Field; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.github.victools.jsonschema.generator.Module; +import com.github.victools.jsonschema.generator.SchemaGenerationContext; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.generator.TypeScope; + +/** + * Module that injects descriptions for enum constants declared using the {@code @JsonPropertyDescription} annotation. This + * should be registered after any modules that would resolve the property description. + */ +public class EnumDescriptionsModule implements Module { + + @Override + public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) { + builder.forTypesInGeneral().withTypeAttributeOverride(this::overrideAttributes); + builder.forFields().withInstanceAttributeOverride(this::overrideAttributes); + builder.forMethods().withInstanceAttributeOverride(this::overrideAttributes); + } + + protected void overrideAttributes( + ObjectNode attributes, TypeScope scope, SchemaGenerationContext context) { + // if description was resolved for property of type enum, resolve descriptions + // for enum constants and add them to the description + JsonNode node = attributes.get("description"); + if (node == null || !node.isTextual()) { + return; + } + Class clazz = scope.getType().getErasedType(); + if (!clazz.isEnum()) { + return; + } + StringBuilder sb = new StringBuilder(); + for (Object obj : clazz.getEnumConstants()) { + if (obj instanceof Enum) { + try { + Field field = clazz.getField(((Enum) obj).name()); + JsonPropertyDescription description = field.getAnnotation(JsonPropertyDescription.class); + if (description != null) { + String propertyName = Optional.ofNullable(field.getAnnotation(JsonProperty.class)) + .map(ann -> ann.value()) + .orElse(obj.toString()); + sb.append("\n * `").append(propertyName) + .append("` - ").append(description.value()); + } + } catch (ReflectiveOperationException e) { + // do nothing + } + } + } + if (sb.length() != 0) { + attributes.set("description", TextNode.valueOf(node.asText() + sb.toString())); + } + } +} \ No newline at end of file diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/Fabric8Module.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/Fabric8Module.java new file mode 100644 index 00000000000..c8f2dd85938 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/Fabric8Module.java @@ -0,0 +1,120 @@ +package io.fabric8.crdv2.generator.alt; + +import static io.fabric8.crdv2.generator.alt.GeneratorUtils.convertValue; +import static io.fabric8.crdv2.generator.alt.GeneratorUtils.emptyToNull; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.victools.jsonschema.generator.MemberScope; +import com.github.victools.jsonschema.generator.Module; +import com.github.victools.jsonschema.generator.SchemaGenerationContext; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.generator.TypeScope; + +import io.fabric8.generator.annotation.Default; +import io.fabric8.generator.annotation.Max; +import io.fabric8.generator.annotation.Min; +import io.fabric8.generator.annotation.Nullable; +import io.fabric8.generator.annotation.Pattern; +import io.fabric8.generator.annotation.Required; +import io.fabric8.generator.annotation.ValidationRule; +import io.fabric8.generator.annotation.ValidationRules; +import io.fabric8.kubernetes.api.model.apiextensions.v1.ValidationRuleBuilder; + +/** + * Module that enables injection of attributes from Fabric8 annotations, e.g. {@code @ValidationRule}. + */ +public class Fabric8Module implements Module { + + @Override + public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) { + builder.forTypesInGeneral().withTypeAttributeOverride(this::overrideTypeAttributes); + + builder.forFields().withDefaultResolver(this::resolveDefault); + builder.forFields().withStringMaxLengthResolver(this::resolveMaxLength); + builder.forFields().withStringMinLengthResolver(this::resolveMinLength); + builder.forFields().withStringPatternResolver(this::resolvePattern); + builder.forFields().withNullableCheck(this::checkNullable); + builder.forFields().withRequiredCheck(this::checkRequired); + builder.forFields().withInstanceAttributeOverride(this::overrideInstanceAttributes); + + builder.forMethods().withDefaultResolver(this::resolveDefault); + builder.forMethods().withStringMaxLengthResolver(this::resolveMaxLength); + builder.forMethods().withStringMinLengthResolver(this::resolveMinLength); + builder.forMethods().withStringPatternResolver(this::resolvePattern); + builder.forMethods().withNullableCheck(this::checkNullable); + builder.forMethods().withRequiredCheck(this::checkRequired); + builder.forMethods().withInstanceAttributeOverride(this::overrideInstanceAttributes); + } + + public Object resolveDefault(MemberScope member) { + return getFromAnnotation(member, Default.class, Default::value).orElse(null); + } + + public Integer resolveMaxLength(MemberScope member) { + return getFromAnnotation(member, Max.class, ann -> (int) ann.value()).orElse(null); + } + + public Integer resolveMinLength(MemberScope member) { + return getFromAnnotation(member, Min.class, ann -> (int) ann.value()).orElse(null); + } + + public String resolvePattern(MemberScope member) { + return getFromAnnotation(member, Pattern.class, Pattern::value).orElse(null); + } + + public Boolean checkNullable(MemberScope member) { + return getFromAnnotation(member, Nullable.class, Function.identity()).isPresent(); + } + + public Boolean checkRequired(MemberScope member) { + return getFromAnnotation(member, Required.class, Function.identity()).isPresent(); + } + + protected void overrideTypeAttributes( + ObjectNode collectedTypeAttributes, TypeScope scope, SchemaGenerationContext context) { + // attach CEL validation rules to node + ValidationRule[] annotations = scope.getType().getErasedType().getAnnotationsByType(ValidationRule.class); + if (annotations.length != 0) { + addValidationRules(collectedTypeAttributes, annotations); + } + } + + protected void overrideInstanceAttributes( + ObjectNode memberAttributes, MemberScope member, SchemaGenerationContext context) { + // attach CEL validation rules to node + ValidationRule[] annotations = getFromAnnotation(member, ValidationRules.class, ValidationRules::value).orElseGet(() -> { + return getFromAnnotation(member, ValidationRule.class, ann -> new ValidationRule[] { ann }) + .orElse(new ValidationRule[0]); + }); + if (annotations.length != 0) { + addValidationRules(memberAttributes, annotations); + } + } + + protected static Optional getFromAnnotation( + MemberScope member, Class clazz, Function fn) { + return Optional.ofNullable(member.getAnnotationConsideringFieldAndGetter(clazz)).map(fn); + } + + protected static void addValidationRules(ObjectNode node, ValidationRule... annotations) { + List rules = Stream.of(annotations).map(ann -> { + return new ValidationRuleBuilder() + .withRule(ann.value()) + .withFieldPath(emptyToNull(ann.fieldPath())) + .withMessage(emptyToNull(ann.message())) + .withMessageExpression(emptyToNull(ann.messageExpression())) + .withOptionalOldSelf(ann.optionalOldSelf() ? true : null) + .withReason(emptyToNull(ann.reason())) + .build(); + }).collect(Collectors.toList()); + node.set("x-kubernetes-validations", convertValue(rules, JsonNode.class)); + } +} \ No newline at end of file diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/GeneratorUtils.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/GeneratorUtils.java new file mode 100644 index 00000000000..ee17d9bf078 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/GeneratorUtils.java @@ -0,0 +1,45 @@ +package io.fabric8.crdv2.generator.alt; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceColumnDefinition; +import io.fabric8.kubernetes.client.CustomResource; + +public class GeneratorUtils { + + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public static T convertValue(Object obj, Class clazz) { + return OBJECT_MAPPER.convertValue(obj, clazz); + } + + public static String emptyToNull(String value) { + return Optional.ofNullable(value).filter(s -> !s.isEmpty()).orElse(null); + } + + public static List getPrinterColumns(Class> crdClass) { + AdditionalPrinterColumn[] annotations = crdClass.getAnnotationsByType(AdditionalPrinterColumn.class); + return Stream.of(annotations).map(ann -> { + CustomResourceColumnDefinition ret = new CustomResourceColumnDefinition().edit() + .withName(ann.name()) + .withJsonPath(ann.jsonPath()) + .withType(ann.type().value) + .build(); + if (ann.format() != AdditionalPrinterColumn.Format.NONE) { + ret.setFormat(ann.format().value); + } + if (!ann.description().isEmpty()) { + ret.setDescription(ann.description()); + } + if (ann.priority() != 0) { + ret.setPriority(ann.priority()); + } + return ret; + }).collect(Collectors.toList()); + } +} diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/CompatibilityTest.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/CompatibilityTest.java new file mode 100644 index 00000000000..1442c64a677 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/CompatibilityTest.java @@ -0,0 +1,67 @@ +package io.fabric8.crdv2.generator.alt.test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; + +import io.fabric8.crdv2.example.joke.JokeRequest; +import io.fabric8.crdv2.generator.CRDGenerator; +import io.fabric8.crdv2.generator.CRDGenerator.CRDOutput; +import io.fabric8.crdv2.generator.CustomResourceInfo; +import io.fabric8.crdv2.generator.alt.ConfigurableCrdGenerator; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceColumnDefinition; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.client.CustomResource; + +public class CompatibilityTest { + + private static final YAMLMapper YAML_MAPPER = new YAMLMapper(); + + private static CustomResourceDefinition standardGenerate(Class> crdClass) throws IOException { + Map outputs = new HashMap<>(); + CRDGenerator generator = new CRDGenerator().withOutput(new CRDOutput() { + + @Override + public void close() throws IOException { + } + + @Override + public ByteArrayOutputStream outputFor(String crdName) throws IOException { + return outputs.computeIfAbsent(crdName, key -> new ByteArrayOutputStream()); + } + }).forCRDVersions("v1").customResources(CustomResourceInfo.fromClass(crdClass)); + + Assertions.assertThat(generator.generate()) + .isEqualTo(outputs.size()) + .isEqualTo(1); + + ByteArrayOutputStream out = outputs.values().iterator().next(); + return YAML_MAPPER.readValue(out.toByteArray(), CustomResourceDefinition.class); + } + + private static CustomResourceDefinition generate(Class> crdClass) { + return new ConfigurableCrdGenerator().generateCrd(crdClass); + } + + @Test + void compareToStandardGenerator() throws IOException { + CustomResourceDefinition expected = standardGenerate(JokeRequest.class); + CustomResourceDefinition actual = generate(JokeRequest.class); + // drop shortNames and additionalPrinterColumns; shortNames is not set by ConfigurableCrdGenerator, + // and additionalPrinterColumns is set using different annotation + expected.getSpec().getNames().setShortNames(Collections.emptyList()); + expected.getSpec().getVersions().forEach(v -> v.setAdditionalPrinterColumns(Collections.emptyList())); + Assertions.assertThat(actual) + .usingRecursiveComparison() + .ignoringFieldsOfTypes(CustomResourceColumnDefinition.class) + .ignoringCollectionOrder() + .isEqualTo(expected); + } +} diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/ExtraFeaturesTest.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/ExtraFeaturesTest.java new file mode 100644 index 00000000000..fc643137a31 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/ExtraFeaturesTest.java @@ -0,0 +1,79 @@ +package io.fabric8.crdv2.generator.alt.test; + +import java.io.IOException; + +import org.assertj.core.api.Assertions; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.databind.node.TextNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; + +import io.fabric8.crdv2.generator.alt.AdditionalPrinterColumn; +import io.fabric8.crdv2.generator.alt.ConfigurableCrdGenerator; +import io.fabric8.crdv2.generator.alt.EnumDescriptionsModule; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +public class ExtraFeaturesTest { + + public static final YAMLMapper YAML_MAPPER = new YAMLMapper(); + + private ConfigurableCrdGenerator generator; + + public ExtraFeaturesTest() { + generator = new ConfigurableCrdGenerator() + .register(new EnumDescriptionsModule()); + } + + @Test + public void test() throws IOException { + CustomResourceDefinition crd = generator.generateCrd(Resource.class); + System.out.println(YAML_MAPPER.writeValueAsString(crd)); + Assertions.assertThat(crd.getSpec().getVersions()).hasSize(1).element(0) + .satisfies(v -> { + // check additionalPrinterColumns + Assertions.assertThat(v.getAdditionalPrinterColumns()).allSatisfy(col -> { + Assertions.assertThat(col) + .hasFieldOrPropertyWithValue("jsonPath", ".status.state") + .hasFieldOrPropertyWithValue("name", "State"); + }); + }) + .extracting("schema.openAPIV3Schema.properties.status.properties") + .hasFieldOrPropertyWithValue("state.description", "The state of the resource\n" + + " * `Good` - Indicates the resource is good\n" + + " * `NotGood` - Indicates the resource is not good") + .extracting("state.enum") + .asInstanceOf(InstanceOfAssertFactories.list(TextNode.class)) + .map(v -> v.asText()) + .containsExactly("Good", "NotGood"); + } + + @Group("io.fabric8.test") + @Version("v1alpha1") + @AdditionalPrinterColumn(jsonPath = ".status.state", name = "State") + public static class Resource extends CustomResource { + + private static final long serialVersionUID = -9088417009209691746L; + } + + public static class Status { + + @JsonPropertyDescription("The state of the resource") + public State state; + } + + public static enum State { + + @JsonPropertyDescription("Indicates the resource is good") + @JsonProperty("Good") + GOOD, + @JsonPropertyDescription("Indicates the resource is not good") + @JsonProperty("NotGood") + NOT_GOOD + } +} diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/JakartaValidationTest.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/JakartaValidationTest.java new file mode 100644 index 00000000000..ce0d8ad9111 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/JakartaValidationTest.java @@ -0,0 +1,106 @@ +package io.fabric8.crdv2.generator.alt.test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.Assertions; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationModule; +import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationOption; + +import io.fabric8.crdv2.generator.alt.ConfigurableCrdGenerator; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class JakartaValidationTest { + + public static final YAMLMapper YAML_MAPPER = new YAMLMapper(); + + private ConfigurableCrdGenerator generator; + + public JakartaValidationTest() { + generator = new ConfigurableCrdGenerator() + .register(new JakartaValidationModule( + JakartaValidationOption.INCLUDE_PATTERN_EXPRESSIONS, + JakartaValidationOption.NOT_NULLABLE_FIELD_IS_REQUIRED, + JakartaValidationOption.NOT_NULLABLE_METHOD_IS_REQUIRED)); + } + + @Test + public void test() throws IOException { + CustomResourceDefinition crd = generator.generateCrd(Resource.class); + System.out.println(YAML_MAPPER.writeValueAsString(crd)); + Assertions.assertThat(crd.getSpec().getVersions()).hasSize(1).element(0) + .satisfies(v -> { + // check required properties + Assertions.assertThat(v) + .extracting("schema.openAPIV3Schema.properties.spec.required") + .asInstanceOf(InstanceOfAssertFactories.list(String.class)) + .containsExactlyInAnyOrder("stringAlpha", "stringDigit"); + }) + .extracting("schema.openAPIV3Schema.properties.spec.properties") + .hasFieldOrPropertyWithValue("stringAlpha.description", "Non-empty string of alphabetical characters") + .hasFieldOrPropertyWithValue("stringAlpha.minLength", 1L) + .hasFieldOrPropertyWithValue("stringAlpha.pattern", "[A-Za-z]*") + .hasFieldOrPropertyWithValue("stringAlpha.type", "string") + .hasFieldOrPropertyWithValue("stringDigit.description", "Non-empty list of digits") + .hasFieldOrPropertyWithValue("stringDigit.minItems", 1L) + .hasFieldOrPropertyWithValue("stringDigit.maxItems", 10L) + .hasFieldOrPropertyWithValue("stringDigit.type", "array") + .hasFieldOrPropertyWithValue("stringDigit.items.schema.pattern", "[0-9]") + .hasFieldOrPropertyWithValue("stringDigit.items.schema.type", "string") + .hasFieldOrPropertyWithValue("nestedProps.description", "Optional nested properties") + .hasFieldOrPropertyWithValue("nestedProps.properties.floatValue.maximum", 99.0) + .hasFieldOrPropertyWithValue("nestedProps.properties.floatValue.type", "number") + .hasFieldOrPropertyWithValue("nestedProps.properties.map.minProperties", 3L) + .hasFieldOrPropertyWithValue("nestedProps.properties.map.additionalProperties.schema.type", "string") + .hasFieldOrPropertyWithValue("nestedProps.properties.map.type", "object") + .hasFieldOrPropertyWithValue("nestedProps.type", "object"); + } + + @Group("io.fabric8.test") + @Version("v1alpha1") + public static class Resource extends CustomResource { + + private static final long serialVersionUID = -9088417009209691746L; + } + + public static class Spec { + + @JsonPropertyDescription("Non-empty string of alphabetical characters") + @Pattern(regexp = "[A-Za-z]*") + @NotEmpty + @NotNull + public String stringAlpha; + + @JsonPropertyDescription("Non-empty list of digits") + @NotEmpty + @Size(max = 10) + public List<@Pattern(regexp = "[0-9]") String> stringDigit; + + @JsonPropertyDescription("Optional nested properties") + public NestedProperties nestedProps; + } + + public static class NestedProperties { + + public Boolean booleanValue; + public Integer intValue; + @Max(99) + public Double floatValue; + @Size(min = 3) + public Map map; + } +} diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/SwaggerTest.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/SwaggerTest.java new file mode 100644 index 00000000000..94e97d9d498 --- /dev/null +++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/alt/test/SwaggerTest.java @@ -0,0 +1,95 @@ +package io.fabric8.crdv2.generator.alt.test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.Assertions; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.github.victools.jsonschema.module.swagger2.Swagger2Module; + +import io.fabric8.crdv2.generator.alt.ConfigurableCrdGenerator; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public class SwaggerTest { + + public static final YAMLMapper YAML_MAPPER = new YAMLMapper(); + + private ConfigurableCrdGenerator generator; + + public SwaggerTest() { + generator = new ConfigurableCrdGenerator() + .register(new Swagger2Module()); + } + + @Test + public void test() throws IOException { + CustomResourceDefinition crd = generator.generateCrd(Resource.class); + System.out.println(YAML_MAPPER.writeValueAsString(crd)); + Assertions.assertThat(crd.getSpec().getVersions()).hasSize(1).element(0) + .satisfies(v -> { + // check required properties + Assertions.assertThat(v) + .extracting("schema.openAPIV3Schema.properties.spec.required") + .asInstanceOf(InstanceOfAssertFactories.list(String.class)) + .containsExactlyInAnyOrder("stringAlpha", "stringDigit"); + }) + .extracting("schema.openAPIV3Schema.properties.spec.properties") + .hasFieldOrPropertyWithValue("stringAlpha.description", "Non-empty string of alphabetical characters") + .hasFieldOrPropertyWithValue("stringAlpha.minLength", 1L) + .hasFieldOrPropertyWithValue("stringAlpha.pattern", "[A-Za-z]*") + .hasFieldOrPropertyWithValue("stringAlpha.type", "string") + .hasFieldOrPropertyWithValue("stringDigit.description", "Non-empty list of digits") + .hasFieldOrPropertyWithValue("stringDigit.minItems", 1L) + .hasFieldOrPropertyWithValue("stringDigit.maxItems", 10L) + .hasFieldOrPropertyWithValue("stringDigit.type", "array") + .hasFieldOrPropertyWithValue("stringDigit.items.schema.pattern", "[0-9]") + .hasFieldOrPropertyWithValue("stringDigit.items.schema.type", "string") + .hasFieldOrPropertyWithValue("nestedProps.description", "Optional nested properties") + .hasFieldOrPropertyWithValue("nestedProps.properties.floatValue.maximum", 99.0) + .hasFieldOrPropertyWithValue("nestedProps.properties.floatValue.type", "number") + .hasFieldOrPropertyWithValue("nestedProps.properties.map.minProperties", 3L) + .hasFieldOrPropertyWithValue("nestedProps.properties.map.additionalProperties.schema.type", "string") + .hasFieldOrPropertyWithValue("nestedProps.properties.map.type", "object") + .hasFieldOrPropertyWithValue("nestedProps.type", "object"); + } + + @Group("io.fabric8.test") + @Version("v1alpha1") + public static class Resource extends CustomResource { + + private static final long serialVersionUID = -9088417009209691746L; + } + + public static class Spec { + + @Schema(description = "Non-empty string of alphabetical characters", pattern = "[A-Za-z]*", minLength = 1, requiredMode = RequiredMode.REQUIRED) + public String stringAlpha; + + @Schema(description = "Non-empty list of digits", requiredMode = RequiredMode.REQUIRED) + @ArraySchema(schema = @Schema(pattern = "[0-9]"), minItems = 1, maxItems = 10) + public List stringDigit; + + @Schema(description = "Optional nested properties") + public NestedProperties nestedProps; + } + + public static class NestedProperties { + + public Boolean booleanValue; + public Integer intValue; + @Schema(maximum = "99.0") + public Double floatValue; + @Schema(minProperties = 3) + public Map map; + } +} diff --git a/pom.xml b/pom.xml index ddcac5540fb..c7eb9652050 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,9 @@ 4.11.0 5.2.0 2.4-M4-groovy-4.0 + 4.36.0 + 3.1.0 + 2.2.22 1.4.2_1 1.11-8_1