From cc33729e70fd5cccffd2a973d81db14cdadba937 Mon Sep 17 00:00:00 2001
From: Steven Hawkins <shawkins@redhat.com>
Date: Tue, 17 Sep 2024 00:07:46 -0400
Subject: [PATCH] fix(crd-generator): allow limited use of type annotations
 (6322)

fix: allow limited use of type annotations

closes: #6282

Signed-off-by: Steve Hawkins <shawkins@redhat.com>
---
 CHANGELOG.md                                  |  1 +
 .../crdv2/generator/AbstractJsonSchema.java   | 97 ++++++++++++++-----
 .../example/annotated/AnnotatedSpec.java      |  5 +
 .../fabric8/crdv2/example/person/Person.java  |  4 +-
 .../crdv2/generator/v1/JsonSchemaTest.java    | 12 ++-
 .../io/fabric8/generator/annotation/Max.java  |  7 +-
 .../io/fabric8/generator/annotation/Min.java  |  7 +-
 .../fabric8/generator/annotation/Pattern.java |  7 +-
 8 files changed, 108 insertions(+), 32 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e09a1086092..0162cdef84b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
 * Fix #6008: removing the optional dependency on bouncy castle
 * Fix #6230: introduced Quantity.multiply(int) to allow for Quantity multiplication by an integer
 * Fix #6281: use GitHub binary repo for Kube API Tests
+* Fix #6282: Allow annotated types with Pattern, Min, and Max with Lists and Maps and CRD generation
 * Fix #5480: Move `io.fabric8:zjsonpatch` to KubernetesClient project
 
 #### Dependency Upgrade
diff --git a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java
index 241da93eaee..deb3821a537 100644
--- a/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java
+++ b/crd-generator/api-v2/src/main/java/io/fabric8/crdv2/generator/AbstractJsonSchema.java
@@ -20,7 +20,6 @@
 import com.fasterxml.jackson.databind.BeanProperty;
 import com.fasterxml.jackson.databind.JavaType;
 import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;
 import com.fasterxml.jackson.databind.node.JsonNodeFactory;
 import com.fasterxml.jackson.module.jsonSchema.JsonSchema;
 import com.fasterxml.jackson.module.jsonSchema.types.ArraySchema.Items;
@@ -56,6 +55,9 @@
 import org.slf4j.LoggerFactory;
 
 import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.AnnotatedParameterizedType;
+import java.lang.reflect.AnnotatedType;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.util.ArrayList;
@@ -68,6 +70,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
@@ -130,10 +133,9 @@ public Map<String, AnnotationMetadata> getAllPaths(Class<PrinterColumn> clazz) {
 
   /**
    * Creates the JSON schema for the class. This is template method where
-   * sub-classes are supposed to provide specific implementations of abstract methods.
+   * subclasses are supposed to provide specific implementations of abstract methods.
    *
    * @param definition The definition.
-   * @param ignore a potentially empty list of property names to ignore while generating the schema
    * @return The schema.
    */
   private T resolveRoot(Class<?> definition) {
@@ -143,7 +145,7 @@ private T resolveRoot(Class<?> definition) {
       return resolveObject(new LinkedHashMap<>(), schemaSwaps, schema, "kind", "apiVersion", "metadata");
     }
     return resolveProperty(new LinkedHashMap<>(), schemaSwaps, null,
-        resolvingContext.objectMapper.getSerializationConfig().constructType(definition), schema);
+        resolvingContext.objectMapper.getSerializationConfig().constructType(definition), schema, null);
   }
 
   /**
@@ -157,32 +159,47 @@ private static <A extends Annotation> void consumeRepeatingAnnotation(Class<?> b
     }
   }
 
-  void collectValidationRules(BeanProperty beanProperty, List<V> validationRules) {
-    // TODO: the old logic allowed for picking up the annotation from both the getter and the field
-    // this requires a messy hack by convention because there doesn't seem to be a way to all annotations
-    // nor does jackson provide the field
-    if (beanProperty.getMember() instanceof AnnotatedMethod) {
+  Optional<Field> getFieldForMethod(BeanProperty beanProperty) {
+    AnnotatedElement annotated = beanProperty.getMember().getAnnotated();
+    if (annotated instanceof Method) {
       // field first
-      Method m = ((AnnotatedMethod) beanProperty.getMember()).getMember();
+      Method m = (Method) annotated;
       String name = m.getName();
       if (name.startsWith("get") || name.startsWith("set")) {
         name = name.substring(3);
       } else if (name.startsWith("is")) {
         name = name.substring(2);
       }
-      if (name.length() > 0) {
+      if (!name.isEmpty()) {
         name = Character.toLowerCase(name.charAt(0)) + name.substring(1);
       }
+
+      try {
+        return Optional.of(m.getDeclaringClass().getDeclaredField(name));
+      } catch (NoSuchFieldException | SecurityException e) {
+        // ignored
+      }
+    }
+    return Optional.empty();
+  }
+
+  void collectValidationRules(BeanProperty beanProperty, List<V> validationRules) {
+    // TODO: the old logic allowed for picking up the annotation from both the getter and the field
+    // this requires a messy hack by convention because there doesn't seem to be a way to all annotations
+    // nor does jackson provide the field
+    AnnotatedElement member = beanProperty.getMember().getAnnotated();
+    if (member instanceof Method) {
+      Optional<Field> field = getFieldForMethod(beanProperty);
       try {
-        Field f = beanProperty.getMember().getDeclaringClass().getDeclaredField(name);
-        ofNullable(f.getAnnotation(ValidationRule.class)).map(this::from)
+        field.map(f -> f.getAnnotation(ValidationRule.class)).map(this::from)
             .ifPresent(validationRules::add);
-        ofNullable(f.getAnnotation(ValidationRules.class))
+        field.map(f -> f.getAnnotation(ValidationRules.class))
             .ifPresent(ann -> Stream.of(ann.value()).map(this::from).forEach(validationRules::add));
-      } catch (NoSuchFieldException | SecurityException e) {
+      } catch (SecurityException e) {
+        // ignored
       }
       // then method
-      Stream.of(m.getAnnotationsByType(ValidationRule.class)).map(this::from).forEach(validationRules::add);
+      Stream.of(member.getAnnotationsByType(ValidationRule.class)).map(this::from).forEach(validationRules::add);
       return;
     }
 
@@ -225,8 +242,8 @@ public PropertyMetadata(JsonSchema value, BeanProperty beanProperty) {
         StringSchema stringSchema = value.asStringSchema();
         // only set if ValidationSchemaFactoryWrapper is used
         this.pattern = stringSchema.getPattern();
-        this.max = ofNullable(stringSchema.getMaxLength()).map(Integer::doubleValue).orElse(null);
-        this.min = ofNullable(stringSchema.getMinLength()).map(Integer::doubleValue).orElse(null);
+        //this.maxLength = ofNullable(stringSchema.getMaxLength()).map(Integer::doubleValue).orElse(null);
+        //this.minLength = ofNullable(stringSchema.getMinLength()).map(Integer::doubleValue).orElse(null);
       } else {
         // TODO: process the other schema types for validation values
       }
@@ -333,7 +350,7 @@ private T resolveObject(LinkedHashMap<String, String> visited, InternalSchemaSwa
         type = resolvingContext.objectMapper.getSerializationConfig().constructType(propertyMetadata.schemaFrom);
       }
 
-      T schema = resolveProperty(visited, schemaSwaps, name, type, propertySchema);
+      T schema = resolveProperty(visited, schemaSwaps, name, type, propertySchema, beanProperty);
 
       propertyMetadata.updateSchema(schema);
 
@@ -378,15 +395,19 @@ static String toFQN(LinkedHashMap<String, String> visited, String name) {
   }
 
   private T resolveProperty(LinkedHashMap<String, String> visited, InternalSchemaSwaps schemaSwaps, String name,
-      JavaType type, JsonSchema jacksonSchema) {
+      JavaType type, JsonSchema jacksonSchema, BeanProperty beanProperty) {
 
     if (jacksonSchema.isArraySchema()) {
       Items items = jacksonSchema.asArraySchema().getItems();
+      if (items == null) { // raw collection
+        throw new IllegalStateException(String.format("Untyped collection %s", name));
+      }
       if (items.isArrayItems()) {
         throw new IllegalStateException("not yet supported");
       }
       JsonSchema arraySchema = jacksonSchema.asArraySchema().getItems().asSingleItems().getSchema();
-      final T schema = resolveProperty(visited, schemaSwaps, name, type.getContentType(), arraySchema);
+      final T schema = resolveProperty(visited, schemaSwaps, name, type.getContentType(), arraySchema, null);
+      handleTypeAnnotations(schema, beanProperty, List.class, 0);
       return arrayLikeProperty(schema);
     } else if (jacksonSchema.isIntegerSchema()) {
       return singleProperty("integer");
@@ -440,7 +461,8 @@ private T resolveProperty(LinkedHashMap<String, String> visited, InternalSchemaS
       final JavaType valueType = type.getContentType();
       JsonSchema mapValueSchema = ((SchemaAdditionalProperties) ((ObjectSchema) jacksonSchema).getAdditionalProperties())
           .getJsonSchema();
-      T component = resolveProperty(visited, schemaSwaps, name, valueType, mapValueSchema);
+      T component = resolveProperty(visited, schemaSwaps, name, valueType, mapValueSchema, null);
+      handleTypeAnnotations(component, beanProperty, Map.class, 1);
       return mapLikeProperty(component);
     }
 
@@ -464,8 +486,36 @@ private T resolveProperty(LinkedHashMap<String, String> visited, InternalSchemaS
     return res;
   }
 
+  private void handleTypeAnnotations(final T schema, BeanProperty beanProperty, Class<?> containerType, int typeIndex) {
+    if (beanProperty == null || !containerType.equals(beanProperty.getType().getRawClass())) {
+      return;
+    }
+
+    AnnotatedElement member = beanProperty.getMember().getAnnotated();
+    AnnotatedType fieldType = null;
+    AnnotatedType type = null;
+    if (member instanceof Field) {
+      fieldType = ((Field) member).getAnnotatedType();
+    } else if (member instanceof Method) {
+      fieldType = getFieldForMethod(beanProperty).map(Field::getAnnotatedType).orElse(null);
+      type = ((Method) member).getAnnotatedReceiverType();
+    }
+
+    Stream.of(fieldType, type)
+        .filter(o -> !Objects.isNull(o))
+        .filter(AnnotatedParameterizedType.class::isInstance)
+        .map(AnnotatedParameterizedType.class::cast)
+        .map(AnnotatedParameterizedType::getAnnotatedActualTypeArguments)
+        .map(a -> a[typeIndex])
+        .forEach(at -> {
+          Optional.ofNullable(at.getAnnotation(Pattern.class)).ifPresent(a -> schema.setPattern(a.value()));
+          Optional.ofNullable(at.getAnnotation(Min.class)).ifPresent(a -> schema.setMinimum(a.value()));
+          Optional.ofNullable(at.getAnnotation(Max.class)).ifPresent(a -> schema.setMaximum(a.value()));
+        });
+  }
+
   /**
-   * we've added support for ignoring an enum values, which complicates this processing
+   * we've added support for ignoring enum values, which complicates this processing
    * as that is something not supported directly by jackson
    */
   private Set<String> findIgnoredEnumConstants(JavaType type) {
@@ -478,6 +528,7 @@ private Set<String> findIgnoredEnumConstants(JavaType type) {
           Object value = field.get(null);
           toIgnore.add(resolvingContext.objectMapper.convertValue(value, String.class));
         } catch (IllegalArgumentException | IllegalAccessException e) {
+          // ignored
         }
       }
     }
diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/annotated/AnnotatedSpec.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/annotated/AnnotatedSpec.java
index 55cfe8837ab..0ade327e23b 100644
--- a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/annotated/AnnotatedSpec.java
+++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/annotated/AnnotatedSpec.java
@@ -29,6 +29,8 @@
 import lombok.Data;
 
 import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.Map;
 
 @Data
 public class AnnotatedSpec {
@@ -58,6 +60,9 @@ public class AnnotatedSpec {
   private String numFloat;
   private ZonedDateTime issuedAt;
 
+  private List<@Pattern("[a-z].*") String> typeAnnotationCollection;
+  private Map<String, @Min(1) @Max(255) Integer> typeAnnotationMap;
+
   @JsonIgnore
   private int ignoredFoo;
 
diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/person/Person.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/person/Person.java
index 72c57dd688f..ba8d450ffaa 100644
--- a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/person/Person.java
+++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/example/person/Person.java
@@ -15,6 +15,8 @@
  */
 package io.fabric8.crdv2.example.person;
 
+import io.fabric8.generator.annotation.Pattern;
+
 import java.util.List;
 import java.util.Optional;
 
@@ -24,7 +26,7 @@ public class Person {
   public Optional<String> middleName;
   public String lastName;
   public int birthYear;
-  public List<String> hobbies;
+  public List<@Pattern(".*ball") String> hobbies;
   public AddressList addresses;
   public Type type;
 
diff --git a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaTest.java b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaTest.java
index bd55e951a86..6a9f183d8a3 100644
--- a/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaTest.java
+++ b/crd-generator/api-v2/src/test/java/io/fabric8/crdv2/generator/v1/JsonSchemaTest.java
@@ -95,6 +95,8 @@ void shouldCreateJsonSchemaFromClass() {
     assertEquals(2, addressTypes.size());
     assertTrue(addressTypes.contains("home"));
     assertTrue(addressTypes.contains("work"));
+    assertEquals(".*ball", properties.get("hobbies").getItems()
+        .getSchema().getPattern());
 
     schema = JsonSchema.from(Basic.class);
     assertNotNull(schema);
@@ -116,7 +118,7 @@ void shouldAugmentPropertiesSchemaFromAnnotations() throws JsonProcessingExcepti
     assertNotNull(schema);
     Map<String, JSONSchemaProps> properties = assertSchemaHasNumberOfProperties(schema, 2);
     final JSONSchemaProps specSchema = properties.get("spec");
-    Map<String, JSONSchemaProps> spec = assertSchemaHasNumberOfProperties(specSchema, 20);
+    Map<String, JSONSchemaProps> spec = assertSchemaHasNumberOfProperties(specSchema, 22);
 
     // check descriptions are present
     assertTrue(spec.containsKey("from-field"));
@@ -155,6 +157,12 @@ void shouldAugmentPropertiesSchemaFromAnnotations() throws JsonProcessingExcepti
     assertTrue(required.contains("emptySetter2"));
     assertTrue(required.contains("from-getter"));
 
+    assertEquals("[a-z].*", spec.get("typeAnnotationCollection").getItems()
+        .getSchema().getPattern());
+    JSONSchemaProps mapSchema = spec.get("typeAnnotationMap").getAdditionalProperties().getSchema();
+    assertEquals(255, mapSchema.getMaximum());
+    assertEquals(1.0, mapSchema.getMinimum());
+
     // check ignored fields
     assertFalse(spec.containsKey("ignoredFoo"));
     assertFalse(spec.containsKey("ignoredBar"));
@@ -461,7 +469,7 @@ private static class Cyclic1 {
 
   private static class Cyclic2 {
 
-    public Cyclic2 parent[];
+    public Cyclic2[] parent;
 
   }
 
diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/Max.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/Max.java
index 3cbdb5b3ecd..7489307428b 100644
--- a/generator-annotations/src/main/java/io/fabric8/generator/annotation/Max.java
+++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/Max.java
@@ -15,7 +15,10 @@
  */
 package io.fabric8.generator.annotation;
 
-import java.lang.annotation.*;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
 
 /**
  * Java representation of the {@code maximum} field of JSONSchemaProps.
@@ -25,7 +28,7 @@
  *      Kubernetes Docs - API Reference - CRD v1 - JSONSchemaProps
  *      </a>
  */
-@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD })
+@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE_USE })
 @Retention(RetentionPolicy.RUNTIME)
 public @interface Max {
   double value();
diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/Min.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/Min.java
index 4b40b666eac..2a472ceedb4 100644
--- a/generator-annotations/src/main/java/io/fabric8/generator/annotation/Min.java
+++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/Min.java
@@ -15,7 +15,10 @@
  */
 package io.fabric8.generator.annotation;
 
-import java.lang.annotation.*;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
 
 /**
  * Java representation of the {@code minimum} field of JSONSchemaProps.
@@ -25,7 +28,7 @@
  *      Kubernetes Docs - API Reference - CRD v1 - JSONSchemaProps
  *      </a>
  */
-@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD })
+@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE_USE })
 @Retention(RetentionPolicy.RUNTIME)
 public @interface Min {
   double value();
diff --git a/generator-annotations/src/main/java/io/fabric8/generator/annotation/Pattern.java b/generator-annotations/src/main/java/io/fabric8/generator/annotation/Pattern.java
index def7c5fdaa9..5335131a197 100644
--- a/generator-annotations/src/main/java/io/fabric8/generator/annotation/Pattern.java
+++ b/generator-annotations/src/main/java/io/fabric8/generator/annotation/Pattern.java
@@ -15,7 +15,10 @@
  */
 package io.fabric8.generator.annotation;
 
-import java.lang.annotation.*;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
 
 /**
  * Java representation of the {@code pattern} field of JSONSchemaProps.
@@ -25,7 +28,7 @@
  *      Kubernetes Docs - API Reference - CRD v1 - JSONSchemaProps
  *      </a>
  */
-@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD })
+@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE_USE })
 @Retention(RetentionPolicy.RUNTIME)
 public @interface Pattern {
   String value();