diff --git a/crd-generator/api/src/main/java/io/fabric8/crd/generator/AbstractJsonSchema.java b/crd-generator/api/src/main/java/io/fabric8/crd/generator/AbstractJsonSchema.java index 2bd89f23194..4b57cff8200 100644 --- a/crd-generator/api/src/main/java/io/fabric8/crd/generator/AbstractJsonSchema.java +++ b/crd-generator/api/src/main/java/io/fabric8/crd/generator/AbstractJsonSchema.java @@ -20,6 +20,7 @@ import io.fabric8.crd.generator.InternalSchemaSwaps.SwapResult; import io.fabric8.crd.generator.annotation.SchemaSwap; import io.fabric8.crd.generator.utils.Types; +import io.fabric8.generator.annotation.ValidationRule; import io.fabric8.kubernetes.api.model.Duration; import io.fabric8.kubernetes.api.model.IntOrString; import io.fabric8.kubernetes.api.model.Quantity; @@ -48,6 +49,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import static io.sundr.model.utils.Types.BOOLEAN_REF; import static io.sundr.model.utils.Types.DOUBLE_REF; @@ -111,6 +113,8 @@ public abstract class AbstractJsonSchema { public static final String ANNOTATION_PERSERVE_UNKNOWN_FIELDS = "io.fabric8.crd.generator.annotation.PreserveUnknownFields"; public static final String ANNOTATION_SCHEMA_SWAP = "io.fabric8.crd.generator.annotation.SchemaSwap"; public static final String ANNOTATION_SCHEMA_SWAPS = "io.fabric8.crd.generator.annotation.SchemaSwaps"; + public static final String ANNOTATION_VALIDATION_RULE = "io.fabric8.generator.annotation.ValidationRule"; + public static final String ANNOTATION_VALIDATION_RULES = "io.fabric8.generator.annotation.ValidationRules"; public static final String JSON_NODE_TYPE = "com.fasterxml.jackson.databind.JsonNode"; public static final String ANY_TYPE = "io.fabric8.kubernetes.api.model.AnyType"; @@ -150,8 +154,8 @@ protected static class SchemaPropsOptions { final String pattern; final boolean nullable; final boolean required; - final boolean preserveUnknownFields; + final List validationRules; SchemaPropsOptions() { defaultValue = null; @@ -161,9 +165,11 @@ protected static class SchemaPropsOptions { nullable = false; required = false; preserveUnknownFields = false; + validationRules = null; } public SchemaPropsOptions(String defaultValue, Double min, Double max, String pattern, + List validationRules, boolean nullable, boolean required, boolean preserveUnknownFields) { this.defaultValue = defaultValue; this.min = min; @@ -172,6 +178,7 @@ public SchemaPropsOptions(String defaultValue, Double min, Double max, String pa this.nullable = nullable; this.required = required; this.preserveUnknownFields = preserveUnknownFields; + this.validationRules = validationRules; } public Optional getDefault() { @@ -201,6 +208,11 @@ public boolean getRequired() { public boolean isPreserveUnknownFields() { return preserveUnknownFields; } + + public Optional> getValidationRules() { + return Optional.ofNullable(validationRules) + .flatMap(rules -> rules.isEmpty() ? Optional.empty() : Optional.of(rules)); + } } /** @@ -332,6 +344,7 @@ private T internalFromImpl(TypeDef definition, Set visited, InternalSche facade.min, facade.max, facade.pattern, + facade.validationRules, facade.nullable, facade.required, facade.preserveUnknownFields); @@ -362,6 +375,7 @@ private static class PropertyOrAccessor { private Double min; private Double max; private String pattern; + private List validationRules; private boolean nullable; private boolean required; private boolean ignored; @@ -428,6 +442,14 @@ public void process() { case ANNOTATION_SCHEMA_FROM: schemaFrom = extractClassRef(a.getParameters().get("type")); break; + case ANNOTATION_VALIDATION_RULE: + validationRules = Collections.singletonList(KubernetesValidationRule.from(a)); + break; + case ANNOTATION_VALIDATION_RULES: + validationRules = Arrays.stream(((ValidationRule[]) a.getParameters().get(VALUE))) + .map(KubernetesValidationRule::from) + .collect(Collectors.toList()); + break; } }); } @@ -456,6 +478,10 @@ public Optional getPattern() { return Optional.ofNullable(pattern); } + public Optional> getValidationRules() { + return Optional.ofNullable(validationRules); + } + public boolean isRequired() { return required; } @@ -510,6 +536,7 @@ private static class PropertyFacade { private String nameContributedBy; private String descriptionContributedBy; private TypeRef schemaFrom; + private List validationRules; public PropertyFacade(Property property, Map potentialAccessors, ClassRef schemaSwap) { original = property; @@ -533,6 +560,7 @@ public PropertyFacade(Property property, Map potentialAccessors, min = null; max = null; pattern = null; + validationRules = null; } public Property process() { @@ -562,6 +590,7 @@ public Property process() { min = p.getMin().orElse(min); max = p.getMax().orElse(max); pattern = p.getPattern().orElse(pattern); + validationRules = p.getValidationRules().orElse(validationRules); if (p.isNullable()) { nullable = true; @@ -588,6 +617,69 @@ public Property process() { } } + protected static class KubernetesValidationRule { + protected String fieldPath; + protected String message; + protected String messageExpression; + protected Boolean optionalOldSelf; + protected String reason; + protected String rule; + + public String getFieldPath() { + return fieldPath; + } + + public String getMessage() { + return message; + } + + public String getMessageExpression() { + return messageExpression; + } + + public Boolean getOptionalOldSelf() { + return optionalOldSelf; + } + + public String getReason() { + return reason; + } + + public String getRule() { + return rule; + } + + static KubernetesValidationRule from(AnnotationRef annotationRef) { + KubernetesValidationRule result = new KubernetesValidationRule(); + result.rule = (String) annotationRef.getParameters().get(VALUE); + result.reason = mapNotEmpty((String) annotationRef.getParameters().get("reason")); + result.message = mapNotEmpty((String) annotationRef.getParameters().get("message")); + result.messageExpression = mapNotEmpty((String) annotationRef.getParameters().get("messageExpression")); + result.fieldPath = mapNotEmpty((String) annotationRef.getParameters().get("fieldPath")); + result.optionalOldSelf = ((Boolean) annotationRef.getParameters().get("optionalOldSelf")) ? true : null; + return result; + } + + static KubernetesValidationRule from(ValidationRule validationRule) { + KubernetesValidationRule result = new KubernetesValidationRule(); + result.rule = validationRule.value(); + result.reason = mapNotEmpty(validationRule.reason()); + result.message = mapNotEmpty(validationRule.message()); + result.messageExpression = mapNotEmpty(validationRule.messageExpression()); + result.fieldPath = mapNotEmpty(validationRule.fieldPath()); + result.optionalOldSelf = validationRule.optionalOldSelf() ? true : null; + return result; + } + + private static String mapNotEmpty(String s) { + if (s == null) + return null; + if (s.isEmpty()) + return null; + return s; + } + } + private boolean isPotentialAccessor(Method method) { final String name = method.getName(); return name.startsWith("is") || name.startsWith("get") || name.startsWith("set"); diff --git a/crd-generator/api/src/main/java/io/fabric8/crd/generator/v1/JsonSchema.java b/crd-generator/api/src/main/java/io/fabric8/crd/generator/v1/JsonSchema.java index b5029a25821..ff554f8ddb6 100644 --- a/crd-generator/api/src/main/java/io/fabric8/crd/generator/v1/JsonSchema.java +++ b/crd-generator/api/src/main/java/io/fabric8/crd/generator/v1/JsonSchema.java @@ -20,11 +20,14 @@ import io.fabric8.crd.generator.AbstractJsonSchema; 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.ValidationRule; +import io.fabric8.kubernetes.api.model.apiextensions.v1.ValidationRuleBuilder; import io.sundr.model.Property; import io.sundr.model.TypeDef; import io.sundr.model.TypeRef; import java.util.List; +import java.util.stream.Collectors; import static io.fabric8.crd.generator.CRDGenerator.YAML_MAPPER; @@ -77,6 +80,10 @@ public void addProperty(Property property, JSONSchemaPropsBuilder builder, options.getMax().ifPresent(schema::setMaximum); options.getPattern().ifPresent(schema::setPattern); + options.getValidationRules() + .map(this::mapValidationRules) + .ifPresent(schema::setXKubernetesValidations); + if (options.isNullable()) { schema.setNullable(true); } @@ -139,4 +146,21 @@ protected JSONSchemaProps addDescription(JSONSchemaProps schema, String descript .withDescription(description) .build(); } + + private List mapValidationRules(List validationRules) { + return validationRules.stream() + .map(this::mapValidationRule) + .collect(Collectors.toList()); + } + + private ValidationRule mapValidationRule(KubernetesValidationRule validationRule) { + return new ValidationRuleBuilder() + .withRule(validationRule.getRule()) + .withMessage(validationRule.getMessage()) + .withMessageExpression(validationRule.getMessageExpression()) + .withReason(validationRule.getReason()) + .withFieldPath(validationRule.getFieldPath()) + .withOptionalOldSelf(validationRule.getOptionalOldSelf()) + .build(); + } } diff --git a/crd-generator/api/src/main/java/io/fabric8/crd/generator/v1beta1/JsonSchema.java b/crd-generator/api/src/main/java/io/fabric8/crd/generator/v1beta1/JsonSchema.java index c5394dd39b5..2f4c8976234 100644 --- a/crd-generator/api/src/main/java/io/fabric8/crd/generator/v1beta1/JsonSchema.java +++ b/crd-generator/api/src/main/java/io/fabric8/crd/generator/v1beta1/JsonSchema.java @@ -20,11 +20,14 @@ import io.fabric8.crd.generator.AbstractJsonSchema; import io.fabric8.kubernetes.api.model.apiextensions.v1beta1.JSONSchemaProps; import io.fabric8.kubernetes.api.model.apiextensions.v1beta1.JSONSchemaPropsBuilder; +import io.fabric8.kubernetes.api.model.apiextensions.v1beta1.ValidationRule; +import io.fabric8.kubernetes.api.model.apiextensions.v1beta1.ValidationRuleBuilder; import io.sundr.model.Property; import io.sundr.model.TypeDef; import io.sundr.model.TypeRef; import java.util.List; +import java.util.stream.Collectors; import static io.fabric8.crd.generator.CRDGenerator.YAML_MAPPER; @@ -78,6 +81,10 @@ public void addProperty(Property property, JSONSchemaPropsBuilder builder, options.getMax().ifPresent(schema::setMaximum); options.getPattern().ifPresent(schema::setPattern); + options.getValidationRules() + .map(this::mapValidationRules) + .ifPresent(schema::setXKubernetesValidations); + if (options.isNullable()) { schema.setNullable(true); } @@ -142,4 +149,21 @@ protected JSONSchemaProps addDescription(JSONSchemaProps schema, String descript .withDescription(description) .build(); } + + private List mapValidationRules(List validationRules) { + return validationRules.stream() + .map(this::mapValidationRule) + .collect(Collectors.toList()); + } + + private ValidationRule mapValidationRule(KubernetesValidationRule validationRule) { + return new ValidationRuleBuilder() + .withRule(validationRule.getRule()) + .withMessage(validationRule.getMessage()) + .withMessageExpression(validationRule.getMessageExpression()) + .withReason(validationRule.getReason()) + .withFieldPath(validationRule.getFieldPath()) + .withOptionalOldSelf(validationRule.getOptionalOldSelf()) + .build(); + } } diff --git a/crd-generator/api/src/test/java/io/fabric8/crd/example/annotated/AnnotatedSpec.java b/crd-generator/api/src/test/java/io/fabric8/crd/example/annotated/AnnotatedSpec.java index 51a091d2b92..3d7a427400c 100644 --- a/crd-generator/api/src/test/java/io/fabric8/crd/example/annotated/AnnotatedSpec.java +++ b/crd-generator/api/src/test/java/io/fabric8/crd/example/annotated/AnnotatedSpec.java @@ -24,6 +24,7 @@ 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 lombok.Data; @Data @@ -54,6 +55,14 @@ public class AnnotatedSpec { private boolean ignoredBar; + @ValidationRule("a.rule") + private String kubernetesValidation; + + @ValidationRule("a.rule") + @ValidationRule("a.second.rule") + @ValidationRule("a.third.rule") + private String kubernetesValidations; + @JsonProperty("from-getter") @JsonPropertyDescription("from-getter-description") @Required diff --git a/crd-generator/api/src/test/java/io/fabric8/crd/generator/v1/JsonSchemaTest.java b/crd-generator/api/src/test/java/io/fabric8/crd/generator/v1/JsonSchemaTest.java index 52e9fb27ba0..42cd9c6534e 100644 --- a/crd-generator/api/src/test/java/io/fabric8/crd/generator/v1/JsonSchemaTest.java +++ b/crd-generator/api/src/test/java/io/fabric8/crd/generator/v1/JsonSchemaTest.java @@ -32,6 +32,7 @@ import io.fabric8.crd.generator.utils.Types; import io.fabric8.kubernetes.api.model.AnyType; import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; +import io.fabric8.kubernetes.api.model.apiextensions.v1.ValidationRule; import io.sundr.model.TypeDef; import org.junit.jupiter.api.Test; @@ -102,7 +103,7 @@ void shouldAugmentPropertiesSchemaFromAnnotations() throws JsonProcessingExcepti assertNotNull(schema); Map properties = assertSchemaHasNumberOfProperties(schema, 2); final JSONSchemaProps specSchema = properties.get("spec"); - Map spec = assertSchemaHasNumberOfProperties(specSchema, 13); + Map spec = assertSchemaHasNumberOfProperties(specSchema, 15); // check descriptions are present assertTrue(spec.containsKey("from-field")); @@ -177,6 +178,16 @@ void shouldAugmentPropertiesSchemaFromAnnotations() throws JsonProcessingExcepti // check ignored fields assertFalse(spec.containsKey("ignoredFoo")); assertFalse(spec.containsKey("ignoredBar")); + + final JSONSchemaProps kubernetesValidation = spec.get("kubernetesValidation"); + final List kubernetesValidationRules = kubernetesValidation.getXKubernetesValidations(); + assertNotNull(kubernetesValidationRules); + assertEquals(1, kubernetesValidationRules.size()); + + final JSONSchemaProps kubernetesValidationsRepeated = spec.get("kubernetesValidations"); + final List kubernetesValidationsRepeatedRules = kubernetesValidationsRepeated.getXKubernetesValidations(); + assertNotNull(kubernetesValidationsRepeatedRules); + assertEquals(3, kubernetesValidationsRepeatedRules.size()); } @Test diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/ValidationRule.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/ValidationRule.java new file mode 100644 index 00000000000..8dff15cff1b --- /dev/null +++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/ValidationRule.java @@ -0,0 +1,67 @@ +package io.fabric8.generator.annotation; + +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; + +@Repeatable(ValidationRules.class) +@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidationRule { + + /** + * @return the validation rule + * + * Kubernetes Docs - CRD Validation - rule + * + */ + String value(); + + /** + * @return the message + * @see + * Kubernetes Docs - CRD Validation - message + * + */ + String message() default ""; + + /** + * @return the messageExpression + * @see + * Kubernetes Docs - CRD Validation - messageExpression + * + */ + String messageExpression() default ""; + + /** + * @return the reason + * @see + * Kubernetes Docs - CRD Validation - reason + * + */ + String reason() default ""; + + /** + * @return the fieldPath + * @see + * Kubernetes Docs - CRD Validation - fieldPath + * + */ + String fieldPath() default ""; + + /** + * @return optionalOldSelf + * @see + * Kubernetes Docs - CRD Validation - optionalOldSelf + * + */ + boolean optionalOldSelf() default false; +} diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/ValidationRules.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/ValidationRules.java new file mode 100644 index 00000000000..2ede9793097 --- /dev/null +++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/ValidationRules.java @@ -0,0 +1,15 @@ +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; + +/** + * https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules + */ +@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidationRules { + ValidationRule[] value(); +}