diff --git a/.gitignore b/.gitignore index 26c6288..f01357a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ build/ ### Local Development ### application-local.yml +/.output.txt diff --git a/src/main/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtil.java b/src/main/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtil.java index eac90c5..9bcd0fc 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtil.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtil.java @@ -36,7 +36,10 @@ public String[] getScannedPackages() { @SuppressWarnings("unchecked") public Set> getSubTypesOf(Class clazz) { - return scanResult.getClassesImplementing(clazz).loadClasses() + var classInfoList = clazz.isInterface() + ? scanResult.getClassesImplementing(clazz) + : scanResult.getSubclasses(clazz); + return classInfoList.loadClasses() .stream() .map(item -> (Class) item) .collect(Collectors.toSet()); diff --git a/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMeta.java b/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMeta.java index f2786a5..82f70a5 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMeta.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMeta.java @@ -34,4 +34,8 @@ public class SwaggerMeta { @Nullable private String mapKeyTypeFqn = null; + + @Nullable + @JsonProperty("isNullable") + private Boolean isNullable = null; } diff --git a/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMetaUtil.java b/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMetaUtil.java index 45dd0d7..13704cf 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMetaUtil.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMetaUtil.java @@ -63,6 +63,14 @@ public static String setIsNestedStructure(@Nullable String currentMeta, boolean return OBJECT_MAPPER.writeValueAsString(meta); } + @SneakyThrows(JsonProcessingException.class) + public static String setIsNullable(@Nullable String currentMeta, boolean value) { + var meta = getSwaggerMeta(currentMeta); + meta.setIsNullable(!value ? null : true); + + return OBJECT_MAPPER.writeValueAsString(meta); + } + private static SwaggerMeta getSwaggerMeta(@Nullable String currentMeta) { var meta = new SwaggerMeta(); if (currentMeta != null) { diff --git a/src/main/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizer.java b/src/main/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizer.java index 749e71f..7f4efff 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizer.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizer.java @@ -2,12 +2,17 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.Schema; +import it.aboutbits.springboot.toolbox.swagger.SwaggerMetaUtil; import org.jspecify.annotations.NullMarked; import org.springdoc.core.customizers.OpenApiCustomizer; import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedArrayType; +import java.lang.reflect.AnnotatedParameterizedType; import java.lang.reflect.AnnotatedType; +import java.lang.reflect.ParameterizedType; import java.util.ArrayList; +import java.util.Collection; import java.util.Map; @NullMarked @@ -57,9 +62,98 @@ private static void processProperties( } else { requiredProperties.remove(propertyName); } + + // Check for nullable type parameters in collections/arrays + var annotatedType = getAnnotatedType(cls, propertyName); + if (annotatedType != null) { + var nullableDepths = new ArrayList(); + findNullableDepths(annotatedType, 0, nullableDepths); + // Add "nullable" description at each depth where nullable elements are found + for (var depth : nullableDepths) { + addNullableDescriptionAtDepth(property, depth); + } + } }); } + @org.jspecify.annotations.Nullable + private static AnnotatedType getAnnotatedType(Class cls, String propertyName) { + var currentClass = cls; + while (currentClass != null) { + try { + var field = currentClass.getDeclaredField(propertyName); + return field.getAnnotatedType(); + } catch (NoSuchFieldException _) { + } + + for (var method : currentClass.getDeclaredMethods()) { + if (method.getName().equals(propertyName) + || method.getName().equals("get" + capitalize(propertyName)) + || method.getName().equals("is" + capitalize(propertyName))) { + return method.getAnnotatedReturnType(); + } + } + + currentClass = currentClass.getSuperclass(); + } + return null; + } + + private static void findNullableDepths(AnnotatedType annotatedType, int depth, ArrayList nullableDepths) { + if (annotatedType instanceof AnnotatedParameterizedType parameterizedType) { + var rawType = parameterizedType.getType(); + if (rawType instanceof ParameterizedType pt) { + var rawClass = pt.getRawType(); + if (rawClass instanceof Class clazz && isCollectionType(clazz)) { + var typeArgs = parameterizedType.getAnnotatedActualTypeArguments(); + for (var typeArg : typeArgs) { + if (hasNullableAnnotation(typeArg)) { + nullableDepths.add(depth); + } + // Recursively check nested type parameters + findNullableDepths(typeArg, depth + 1, nullableDepths); + } + } + } + } else if (annotatedType instanceof AnnotatedArrayType arrayType) { + var componentType = arrayType.getAnnotatedGenericComponentType(); + if (hasNullableAnnotation(componentType)) { + nullableDepths.add(depth); + } + // Recursively check nested array types + findNullableDepths(componentType, depth + 1, nullableDepths); + } + } + + @SuppressWarnings("rawtypes") + private static void addNullableDescriptionAtDepth(Schema schema, int depth) { + Schema currentSchema = schema; + for (int i = 0; i <= depth; i++) { + var items = currentSchema.getItems(); + if (items == null) { + return; // Schema structure doesn't match expected depth + } + currentSchema = items; + } + currentSchema.setDescription(SwaggerMetaUtil.setIsNullable( + currentSchema.getDescription(), + true + )); + } + + private static boolean isCollectionType(Class clazz) { + return Collection.class.isAssignableFrom(clazz) || clazz.isArray(); + } + + private static boolean hasNullableAnnotation(AnnotatedType annotatedType) { + for (var annotation : annotatedType.getAnnotations()) { + if (annotation.annotationType().getSimpleName().equals("Nullable")) { + return true; + } + } + return false; + } + @org.jspecify.annotations.Nullable private static Class loadClass(String fqn) { try { diff --git a/src/main/java/it/aboutbits/springboot/toolbox/type/EmailAddress.java b/src/main/java/it/aboutbits/springboot/toolbox/type/EmailAddress.java index 14dbc32..b5eaf22 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/type/EmailAddress.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/type/EmailAddress.java @@ -26,6 +26,11 @@ public EmailAddress(@Nullable String value) { this.value = value.toLowerCase(); } + @SuppressWarnings("unused") + EmailAddress(EmailAddress other) { + this(other.value); + } + @Override public String toString() { return value; diff --git a/src/main/java/it/aboutbits/springboot/toolbox/type/Iban.java b/src/main/java/it/aboutbits/springboot/toolbox/type/Iban.java index 44daf9d..1645b7f 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/type/Iban.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/type/Iban.java @@ -22,6 +22,11 @@ public Iban(@Nullable String value) { this.value = value.toUpperCase(); } + @SuppressWarnings("unused") + Iban(Iban other) { + this(other.value); + } + @Override public String toString() { return value; diff --git a/src/main/java/it/aboutbits/springboot/toolbox/type/ScaledBigDecimal.java b/src/main/java/it/aboutbits/springboot/toolbox/type/ScaledBigDecimal.java index 7f24b0b..d8d7bde 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/type/ScaledBigDecimal.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/type/ScaledBigDecimal.java @@ -23,6 +23,11 @@ public record ScaledBigDecimal( public static final ScaledBigDecimal TWO = new ScaledBigDecimal(2); public static final ScaledBigDecimal TEN = new ScaledBigDecimal(10); + @SuppressWarnings("unused") + ScaledBigDecimal(ScaledBigDecimal other) { + this(other.value); + } + public ScaledBigDecimal(BigDecimal value) { this.value = value.setScale(MATH_CONTEXT.getPrecision(), MATH_CONTEXT.getRoundingMode()); } diff --git a/src/test/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizerTest.java b/src/test/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizerTest.java index 8c840f6..5f1acbb 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizerTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizerTest.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.ArraySchema; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; import org.jspecify.annotations.NullUnmarked; @@ -10,6 +11,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -51,6 +54,47 @@ public String directMethod() { } } + // Test classes for nullable type parameters in collections + public static class ListWithNullableElements { + private List<@Nullable String> items; + + public List<@Nullable String> getItems() { + return items; + } + } + + public static class SetWithNullableElements { + private Set<@Nullable String> items; + + public Set<@Nullable String> getItems() { + return items; + } + } + + public static class NestedListWithNullableElements { + private List> nestedItems; + + public List> getNestedItems() { + return nestedItems; + } + } + + public static class ArrayWithNullableElements { + private @Nullable String[] items; + + public @Nullable String[] getItems() { + return items; + } + } + + public static class ListWithNonNullableElements { + private List items; + + public List getItems() { + return items; + } + } + @Test void shouldFindFieldInSuperClass() { // given @@ -189,6 +233,162 @@ void shouldFindCustomNullableAnnotation() { assertThat(required).as("customNullableField should NOT be required").isNullOrEmpty(); } + @Test + void shouldAddDescriptionForListWithNullableElements() { + // given + var customizer = new NullableCustomizer(); + var openApi = new OpenAPI(); + var components = new Components(); + + var schema = new Schema(); + schema.setName(ListWithNullableElements.class.getName()); + var itemsProperty = new ArraySchema(); + itemsProperty.setItems(new StringSchema()); + schema.addProperty("items", itemsProperty); + + components.addSchemas(ListWithNullableElements.class.getName(), schema); + openApi.setComponents(components); + + // when + customizer.customise(openApi); + + // then + var property = (ArraySchema) schema.getProperties().get("items"); + assertThat(property.getItems().getDescription()).as("description should indicate nullable elements") + .isEqualTo("{\"isNullable\":true}"); + } + + @Test + void shouldAddDescriptionForSetWithNullableElements() { + // given + var customizer = new NullableCustomizer(); + var openApi = new OpenAPI(); + var components = new Components(); + + var schema = new Schema(); + schema.setName(SetWithNullableElements.class.getName()); + var itemsProperty = new ArraySchema(); + itemsProperty.setItems(new StringSchema()); + schema.addProperty("items", itemsProperty); + + components.addSchemas(SetWithNullableElements.class.getName(), schema); + openApi.setComponents(components); + + // when + customizer.customise(openApi); + + // then + var property = (ArraySchema) schema.getProperties().get("items"); + assertThat(property.getItems().getDescription()).as("description should indicate nullable elements") + .isEqualTo("{\"isNullable\":true}"); + } + + @Test + void shouldAddDescriptionForNestedListWithNullableElements() { + // given + var customizer = new NullableCustomizer(); + var openApi = new OpenAPI(); + var components = new Components(); + + var schema = new Schema(); + schema.setName(NestedListWithNullableElements.class.getName()); + var nestedItemsProperty = new ArraySchema(); + var innerArray = new ArraySchema(); + innerArray.setItems(new StringSchema()); + nestedItemsProperty.setItems(innerArray); + schema.addProperty("nestedItems", nestedItemsProperty); + + components.addSchemas(NestedListWithNullableElements.class.getName(), schema); + openApi.setComponents(components); + + // when + customizer.customise(openApi); + + // then + var property = (ArraySchema) schema.getProperties().get("nestedItems"); + var innerItems = (ArraySchema) property.getItems(); + assertThat(innerItems.getItems().getDescription()).as("description should indicate nested nullable elements") + .isEqualTo("{\"isNullable\":true}"); + } + + @Test + void shouldAddDescriptionForArrayWithNullableElements() { + // given + var customizer = new NullableCustomizer(); + var openApi = new OpenAPI(); + var components = new Components(); + + var schema = new Schema(); + schema.setName(ArrayWithNullableElements.class.getName()); + var itemsProperty = new ArraySchema(); + itemsProperty.setItems(new StringSchema()); + schema.addProperty("items", itemsProperty); + + components.addSchemas(ArrayWithNullableElements.class.getName(), schema); + openApi.setComponents(components); + + // when + customizer.customise(openApi); + + // then + var property = (ArraySchema) schema.getProperties().get("items"); + assertThat(property.getItems().getDescription()).as("description should indicate nullable elements") + .isEqualTo("{\"isNullable\":true}"); + } + + @Test + void shouldNotAddDescriptionForListWithNonNullableElements() { + // given + var customizer = new NullableCustomizer(); + var openApi = new OpenAPI(); + var components = new Components(); + + var schema = new Schema(); + schema.setName(ListWithNonNullableElements.class.getName()); + var itemsProperty = new ArraySchema(); + itemsProperty.setItems(new StringSchema()); + schema.addProperty("items", itemsProperty); + + components.addSchemas(ListWithNonNullableElements.class.getName(), schema); + openApi.setComponents(components); + + // when + customizer.customise(openApi); + + // then + var property = schema.getProperties().get("items"); + assertThat(property.getDescription()).as("description should be null for non-nullable elements") + .isNull(); + } + + @Test + void shouldAppendToExistingDescription() { + // given + var customizer = new NullableCustomizer(); + var openApi = new OpenAPI(); + var components = new Components(); + + var schema = new Schema(); + schema.setName(ListWithNullableElements.class.getName()); + var itemsProperty = new ArraySchema(); + var itemsSchema = new StringSchema(); + itemsSchema.setDescription("{\"isCustomType\":true}"); + itemsProperty.setItems(itemsSchema); + schema.addProperty("items", itemsProperty); + + components.addSchemas(ListWithNullableElements.class.getName(), schema); + openApi.setComponents(components); + + // when + customizer.customise(openApi); + + // then + var property = (ArraySchema) schema.getProperties().get("items"); + assertThat(property.getItems().getDescription()).as( + "description should append nullable info to existing description") + .isEqualTo("{\"isCustomType\":true,\"isNullable\":true}"); + } + public static class Nest { @Retention(RetentionPolicy.RUNTIME) public @interface Nullable { diff --git a/src/test/java/it/aboutbits/springboot/toolbox/type/EmailAddressTest.java b/src/test/java/it/aboutbits/springboot/toolbox/type/EmailAddressTest.java index 8fc4720..ce2c55e 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/type/EmailAddressTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/type/EmailAddressTest.java @@ -50,7 +50,7 @@ void invalidValues_shouldFail(String value) { void null_shouldFail() { //noinspection DataFlowIssue assertThatIllegalArgumentException().isThrownBy( - () -> new EmailAddress(null) + () -> new EmailAddress((String) null) ); } } diff --git a/src/test/java/it/aboutbits/springboot/toolbox/type/IbanTest.java b/src/test/java/it/aboutbits/springboot/toolbox/type/IbanTest.java index 6a3ae55..ce5c655 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/type/IbanTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/type/IbanTest.java @@ -55,7 +55,7 @@ void invalidValues_shouldFail(String value) { void null_shouldFail() { //noinspection DataFlowIssue assertThatIllegalArgumentException().isThrownBy( - () -> new Iban(null) + () -> new Iban((String) null) ); } }