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