Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

CRD generator implementation that supports Jakarta and Swagger #6315

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions crd-generator/api-v2/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,46 @@
<artifactId>lombok</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator</artifactId>
<version>${victools-jsonschema.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jackson</artifactId>
<version>${victools-jsonschema.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jakarta-validation</artifactId>
<version>${victools-jsonschema.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-swagger-2</artifactId>
<version>${victools-jsonschema.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>${jakarta-validation.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
<version>${swagger-annotations.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
*
* Support for Jakarta Validation API annotations can be enabled using victools/jsonschema-module-jakarta-validation.
* <p>
*
* Support for Swagger annotations can be enabled using victools/jsonschema-module-swagger-2.
*/
public class ConfigurableCrdGenerator {

private final List<Module> 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 <SPEC, STATUS> CustomResourceDefinition generateCrd(Class<? extends CustomResource<SPEC, STATUS>> 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<SPEC> specClass = (Class<SPEC>) typeParams[0];
@SuppressWarnings("unchecked")
Class<STATUS> statusClass = (Class<STATUS>) typeParams[1];
return generateCrd(crdClass, specClass, statusClass);
}

public <SPEC, STATUS> CustomResourceDefinition generateCrd(
Class<? extends CustomResource<SPEC, STATUS>> crdClass,
Class<SPEC> specClass, Class<STATUS> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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()));
}
}
}
Loading