From 63cc4a93586c3e7603674c7544ed5371964c35a5 Mon Sep 17 00:00:00 2001 From: SirCotare Date: Wed, 4 Feb 2026 10:37:00 +0100 Subject: [PATCH] add copy constructors to custom type for jpa implicit dto projection (#53) * add copy constructors to custom type for jpa implicit dto projection * ignore AI file * fix swagger nullability for collections * fix class scanning of sub-types --- .gitignore | 1 + .../reflection/util/ClassScannerUtil.java | 12 +- .../toolbox/swagger/SwaggerMeta.java | 5 + .../toolbox/swagger/SwaggerMetaUtil.java | 19 +- .../default_not_null/NullableCustomizer.java | 94 ++++++++ .../springboot/toolbox/type/EmailAddress.java | 14 +- .../springboot/toolbox/type/Iban.java | 16 +- .../toolbox/type/ScaledBigDecimal.java | 89 ++++---- .../NullableCustomizerTest.java | 201 ++++++++++++++++++ .../toolbox/type/EmailAddressTest.java | 2 +- .../springboot/toolbox/type/IbanTest.java | 2 +- 11 files changed, 382 insertions(+), 73 deletions(-) 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 4aa3859..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 @@ -3,12 +3,13 @@ import io.github.classgraph.ClassGraph; import io.github.classgraph.ClassInfo; import io.github.classgraph.ScanResult; -import lombok.NonNull; +import org.jspecify.annotations.NullMarked; import java.lang.annotation.Annotation; import java.util.Set; import java.util.stream.Collectors; +@NullMarked public final class ClassScannerUtil { private ClassScannerUtil() { } @@ -34,14 +35,17 @@ public String[] getScannedPackages() { } @SuppressWarnings("unchecked") - public Set> getSubTypesOf(@NonNull Class clazz) { - return scanResult.getClassesImplementing(clazz).loadClasses() + public Set> getSubTypesOf(Class clazz) { + var classInfoList = clazz.isInterface() + ? scanResult.getClassesImplementing(clazz) + : scanResult.getSubclasses(clazz); + return classInfoList.loadClasses() .stream() .map(item -> (Class) item) .collect(Collectors.toSet()); } - public Set> getClassesAnnotatedWith(@NonNull Class clazz) { + public Set> getClassesAnnotatedWith(Class clazz) { var result = scanResult.getClassesWithAnnotation(clazz); return result.stream().map( ClassInfo::loadClass 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 0198863..d8d3281 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMeta.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMeta.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; import lombok.Setter; +import org.jspecify.annotations.Nullable; @Getter @Setter @@ -19,4 +20,8 @@ public class SwaggerMeta { @JsonProperty("isMap") private Boolean isMap = null; 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 f1d46ed..13704cf 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMetaUtil.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMetaUtil.java @@ -2,12 +2,13 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.NonNull; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; @Slf4j +@NullMarked public final class SwaggerMetaUtil { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -15,7 +16,7 @@ private SwaggerMetaUtil() { } @SneakyThrows(JsonProcessingException.class) - public static String setMapKeyTypeFqn(@Nullable String currentMeta, @NonNull String value) { + public static String setMapKeyTypeFqn(@Nullable String currentMeta, String value) { var meta = getSwaggerMeta(currentMeta); meta.setMapKeyTypeFqn(value); @@ -31,7 +32,7 @@ public static String setIsMap(@Nullable String currentMeta, boolean value) { } @SneakyThrows(JsonProcessingException.class) - public static String setOriginalTypeFqn(@Nullable String currentMeta, @NonNull String value) { + public static String setOriginalTypeFqn(@Nullable String currentMeta, String value) { var meta = getSwaggerMeta(currentMeta); meta.setOriginalTypeFqn(value); @@ -62,7 +63,15 @@ public static String setIsNestedStructure(@Nullable String currentMeta, boolean return OBJECT_MAPPER.writeValueAsString(meta); } - private static SwaggerMeta getSwaggerMeta(String currentMeta) { + @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) { try { 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 78245aa..b5eaf22 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/type/EmailAddress.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/type/EmailAddress.java @@ -1,7 +1,8 @@ package it.aboutbits.springboot.toolbox.type; import it.aboutbits.springboot.toolbox.validation.util.EmailAddressValidator; -import lombok.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * A record representing an email address, validated and stored in lowercase. @@ -15,8 +16,9 @@ * @param value the email address string * @throws IllegalArgumentException if the provided email address is not in a valid format */ +@NullMarked public record EmailAddress(String value) implements CustomType, Comparable { - public EmailAddress(String value) { + public EmailAddress(@Nullable String value) { if (value == null || EmailAddressValidator.isNotValid(value)) { throw new IllegalArgumentException("Value is not a valid email address: " + value); } @@ -24,14 +26,18 @@ public EmailAddress(String value) { this.value = value.toLowerCase(); } - @NonNull + @SuppressWarnings("unused") + EmailAddress(EmailAddress other) { + this(other.value); + } + @Override public String toString() { return value; } @Override - public int compareTo(@NonNull EmailAddress o) { + public int compareTo(EmailAddress o) { return value().compareTo(o.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 a180661..1645b7f 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/type/Iban.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/type/Iban.java @@ -1,7 +1,8 @@ package it.aboutbits.springboot.toolbox.type; import it.aboutbits.springboot.toolbox.validation.util.IbanValidator; -import lombok.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import java.util.Optional; @@ -11,8 +12,9 @@ * * @param value the IBAN value, which must be a valid and non-null string */ +@NullMarked public record Iban(String value) implements CustomType, Comparable { - public Iban(String value) { + public Iban(@Nullable String value) { if (value == null || IbanValidator.isNotValid(value.toUpperCase())) { throw new IllegalArgumentException("Value is not a valid IBAN: " + value); } @@ -20,7 +22,11 @@ public Iban(String value) { this.value = value.toUpperCase(); } - @NonNull + @SuppressWarnings("unused") + Iban(Iban other) { + this(other.value); + } + @Override public String toString() { return value; @@ -31,7 +37,7 @@ public String toString() { * * @return An {@code Optional} containing the ABI value if the IBAN is Italian, or an empty {@code Optional} otherwise. */ - @NonNull + public Optional getAbiIfItalian() { var iban = value(); @@ -42,7 +48,7 @@ public Optional getAbiIfItalian() { } @Override - public int compareTo(@NonNull Iban o) { + public int compareTo(Iban o) { return value().compareTo(o.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 9c32abd..d8d7bde 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/type/ScaledBigDecimal.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/type/ScaledBigDecimal.java @@ -1,6 +1,6 @@ package it.aboutbits.springboot.toolbox.type; -import lombok.NonNull; +import org.jspecify.annotations.NullMarked; import java.math.BigDecimal; import java.math.BigInteger; @@ -12,8 +12,9 @@ * This class provides various constructors for creating instances with different numerical types. * It also provides a set of arithmetic operations that return new instances with the appropriate scale and rounding. */ +@NullMarked public record ScaledBigDecimal( - @NonNull BigDecimal value + BigDecimal value ) implements CustomType, Comparable { private static final MathContext MATH_CONTEXT = new MathContext(15, RoundingMode.HALF_UP); @@ -22,7 +23,12 @@ public record ScaledBigDecimal( public static final ScaledBigDecimal TWO = new ScaledBigDecimal(2); public static final ScaledBigDecimal TEN = new ScaledBigDecimal(10); - public ScaledBigDecimal(@NonNull BigDecimal value) { + @SuppressWarnings("unused") + ScaledBigDecimal(ScaledBigDecimal other) { + this(other.value); + } + + public ScaledBigDecimal(BigDecimal value) { this.value = value.setScale(MATH_CONTEXT.getPrecision(), MATH_CONTEXT.getRoundingMode()); } @@ -30,11 +36,11 @@ public ScaledBigDecimal(int value) { this(new BigDecimal(value, MATH_CONTEXT)); } - public ScaledBigDecimal(@NonNull Integer value) { + public ScaledBigDecimal(Integer value) { this(new BigDecimal(value, MATH_CONTEXT)); } - public ScaledBigDecimal(@NonNull BigInteger value) { + public ScaledBigDecimal(BigInteger value) { this(new BigDecimal(value, MATH_CONTEXT)); } @@ -42,7 +48,7 @@ public ScaledBigDecimal(long value) { this(new BigDecimal(value, MATH_CONTEXT)); } - public ScaledBigDecimal(@NonNull Long value) { + public ScaledBigDecimal(Long value) { this(new BigDecimal(value, MATH_CONTEXT)); } @@ -50,7 +56,7 @@ public ScaledBigDecimal(float value) { this(new BigDecimal(String.valueOf(value), MATH_CONTEXT)); } - public ScaledBigDecimal(@NonNull Float value) { + public ScaledBigDecimal(Float value) { this(new BigDecimal(String.valueOf(value), MATH_CONTEXT)); } @@ -58,11 +64,11 @@ public ScaledBigDecimal(double value) { this(new BigDecimal(String.valueOf(value), MATH_CONTEXT)); } - public ScaledBigDecimal(@NonNull Double value) { + public ScaledBigDecimal(Double value) { this(new BigDecimal(String.valueOf(value), MATH_CONTEXT)); } - public ScaledBigDecimal(@NonNull String value) { + public ScaledBigDecimal(String value) { this(new BigDecimal(value, MATH_CONTEXT)); } @@ -70,11 +76,11 @@ public static ScaledBigDecimal valueOf(int value) { return new ScaledBigDecimal(value); } - public static ScaledBigDecimal valueOf(@NonNull Integer value) { + public static ScaledBigDecimal valueOf(Integer value) { return new ScaledBigDecimal(value); } - public static ScaledBigDecimal valueOf(@NonNull BigInteger value) { + public static ScaledBigDecimal valueOf(BigInteger value) { return new ScaledBigDecimal(value); } @@ -82,7 +88,7 @@ public static ScaledBigDecimal valueOf(long value) { return new ScaledBigDecimal(value); } - public static ScaledBigDecimal valueOf(@NonNull Long value) { + public static ScaledBigDecimal valueOf(Long value) { return new ScaledBigDecimal(value); } @@ -90,7 +96,7 @@ public static ScaledBigDecimal valueOf(float value) { return new ScaledBigDecimal(value); } - public static ScaledBigDecimal valueOf(@NonNull Float value) { + public static ScaledBigDecimal valueOf(Float value) { return new ScaledBigDecimal(value); } @@ -98,116 +104,95 @@ public static ScaledBigDecimal valueOf(double value) { return new ScaledBigDecimal(value); } - public static ScaledBigDecimal valueOf(@NonNull Double value) { + public static ScaledBigDecimal valueOf(Double value) { return new ScaledBigDecimal(value); } - public static ScaledBigDecimal valueOf(@NonNull String value) { + public static ScaledBigDecimal valueOf(String value) { return new ScaledBigDecimal(value); } - @NonNull - public ScaledBigDecimal add(@NonNull ScaledBigDecimal other) { + public ScaledBigDecimal add(ScaledBigDecimal other) { return new ScaledBigDecimal(this.value().add(other.value())); } - @NonNull - public ScaledBigDecimal subtract(@NonNull ScaledBigDecimal other) { + public ScaledBigDecimal subtract(ScaledBigDecimal other) { return new ScaledBigDecimal(this.value().subtract(other.value())); } - @NonNull - public ScaledBigDecimal multiply(@NonNull ScaledBigDecimal other) { + public ScaledBigDecimal multiply(ScaledBigDecimal other) { return new ScaledBigDecimal(this.value().multiply(other.value())); } - @NonNull - public ScaledBigDecimal divide(@NonNull ScaledBigDecimal other) { + public ScaledBigDecimal divide(ScaledBigDecimal other) { return new ScaledBigDecimal(this.value().divide(other.value(), MATH_CONTEXT)); } - @NonNull - public ScaledBigDecimal add(@NonNull BigDecimal other) { + public ScaledBigDecimal add(BigDecimal other) { return new ScaledBigDecimal(this.value().add(other)); } - @NonNull - public ScaledBigDecimal subtract(@NonNull BigDecimal other) { + public ScaledBigDecimal subtract(BigDecimal other) { return new ScaledBigDecimal(this.value().subtract(other)); } - @NonNull - public ScaledBigDecimal multiply(@NonNull BigDecimal other) { + public ScaledBigDecimal multiply(BigDecimal other) { return new ScaledBigDecimal(this.value().multiply(other)); } - @NonNull - public ScaledBigDecimal divide(@NonNull BigDecimal other) { + public ScaledBigDecimal divide(BigDecimal other) { return new ScaledBigDecimal(this.value().divide(other, MATH_CONTEXT)); } - @NonNull - public ScaledBigDecimal remainder(@NonNull ScaledBigDecimal divisor) { + public ScaledBigDecimal remainder(ScaledBigDecimal divisor) { return new ScaledBigDecimal(this.value().remainder(divisor.value(), MATH_CONTEXT)); } - @NonNull - public ScaledBigDecimal remainder(@NonNull BigDecimal divisor) { + public ScaledBigDecimal remainder(BigDecimal divisor) { return new ScaledBigDecimal(this.value().remainder(divisor, MATH_CONTEXT)); } - @NonNull public ScaledBigDecimal sqrt() { return new ScaledBigDecimal(this.value().sqrt(MATH_CONTEXT)); } - @NonNull public ScaledBigDecimal pow(int n) { return new ScaledBigDecimal(this.value().pow(n, MATH_CONTEXT)); } - @NonNull public ScaledBigDecimal abs() { return new ScaledBigDecimal(this.value().abs(MATH_CONTEXT)); } - @NonNull public ScaledBigDecimal negate() { return new ScaledBigDecimal(this.value().negate(MATH_CONTEXT)); } - @NonNull - public ScaledBigDecimal min(@NonNull ScaledBigDecimal val) { + public ScaledBigDecimal min(ScaledBigDecimal val) { return new ScaledBigDecimal(this.value().min(val.value())); } - @NonNull - public ScaledBigDecimal min(@NonNull BigDecimal val) { + public ScaledBigDecimal min(BigDecimal val) { return new ScaledBigDecimal(this.value().min(val)); } - @NonNull - public ScaledBigDecimal max(@NonNull ScaledBigDecimal val) { + public ScaledBigDecimal max(ScaledBigDecimal val) { return new ScaledBigDecimal(this.value().max(val.value())); } - @NonNull - public ScaledBigDecimal max(@NonNull BigDecimal val) { + public ScaledBigDecimal max(BigDecimal val) { return new ScaledBigDecimal(this.value().max(val)); } - @NonNull public BigDecimal toBigDecimal(int scale) { return this.value().setScale(scale, RoundingMode.HALF_UP); } - @NonNull public ScaledBigDecimal roundToScale(int scale) { return new ScaledBigDecimal(this.value().setScale(scale, RoundingMode.HALF_UP)); } @Override - @NonNull public String toString() { return this.value().toString(); } @@ -231,7 +216,7 @@ public int hashCode() { } @Override - public int compareTo(@NonNull ScaledBigDecimal o) { + public int compareTo(ScaledBigDecimal o) { return this.value().compareTo(o.value()); } @@ -240,7 +225,6 @@ public int compareTo(@NonNull ScaledBigDecimal o) { * This is temporary. We need to avoid changing any rounding logic at the moment. */ @Deprecated - @NonNull public BigDecimal toCurrency() { return this.value().setScale(2, RoundingMode.HALF_UP); } @@ -250,7 +234,6 @@ public BigDecimal toCurrency() { * This is temporary. We need to avoid changing any rounding logic at the moment. */ @Deprecated - @NonNull public ScaledBigDecimal roundToCurrency() { return new ScaledBigDecimal(this.value().setScale(2, RoundingMode.HALF_UP)); } 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 3a344c2..68901fa 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,13 +2,17 @@ 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; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; 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; @@ -50,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 @@ -188,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 bbed40e..d7efab3 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/type/EmailAddressTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/type/EmailAddressTest.java @@ -31,7 +31,7 @@ void invalidValues_shouldFail(String value) { @Test void null_shouldFail() { 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 3b9fe55..d8a49da 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/type/IbanTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/type/IbanTest.java @@ -31,7 +31,7 @@ void invalidValues_shouldFail(String value) { @Test void null_shouldFail() { assertThatIllegalArgumentException().isThrownBy( - () -> new Iban(null) + () -> new Iban((String) null) ); } }