Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ build/

### Local Development ###
application-local.yml
/.output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
Expand All @@ -34,14 +35,17 @@ public String[] getScannedPackages() {
}

@SuppressWarnings("unchecked")
public <T> Set<Class<? extends T>> getSubTypesOf(@NonNull Class<T> clazz) {
return scanResult.getClassesImplementing(clazz).loadClasses()
public <T> Set<Class<? extends T>> getSubTypesOf(Class<T> clazz) {
var classInfoList = clazz.isInterface()
? scanResult.getClassesImplementing(clazz)
: scanResult.getSubclasses(clazz);
return classInfoList.loadClasses()
.stream()
.map(item -> (Class<? extends T>) item)
.collect(Collectors.toSet());
}

public Set<Class<?>> getClassesAnnotatedWith(@NonNull Class<? extends Annotation> clazz) {
public Set<Class<?>> getClassesAnnotatedWith(Class<? extends Annotation> clazz) {
var result = scanResult.getClassesWithAnnotation(clazz);
return result.stream().map(
ClassInfo::loadClass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.Setter;
import org.jspecify.annotations.Nullable;

@Getter
@Setter
Expand All @@ -19,4 +20,8 @@ public class SwaggerMeta {
@JsonProperty("isMap")
private Boolean isMap = null;
private String mapKeyTypeFqn = null;

@Nullable
@JsonProperty("isNullable")
private Boolean isNullable = null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@

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();

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);

Expand All @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Integer>();
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<Integer> 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -15,23 +16,28 @@
* @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<String>, Comparable<EmailAddress> {
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);
}

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());
}
}
16 changes: 11 additions & 5 deletions src/main/java/it/aboutbits/springboot/toolbox/type/Iban.java
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -11,16 +12,21 @@
*
* @param value the IBAN value, which must be a valid and non-null string
*/
@NullMarked
public record Iban(String value) implements CustomType<String>, Comparable<Iban> {
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);
}

this.value = value.toUpperCase();
}

@NonNull
@SuppressWarnings("unused")
Iban(Iban other) {
this(other.value);
}

@Override
public String toString() {
return value;
Expand All @@ -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<String> getAbiIfItalian() {
var iban = value();

Expand All @@ -42,7 +48,7 @@ public Optional<String> getAbiIfItalian() {
}

@Override
public int compareTo(@NonNull Iban o) {
public int compareTo(Iban o) {
return value().compareTo(o.value());
}
}
Loading