Skip to content

Commit 03d63c3

Browse files
authored
fix nullability customizer (#48)
* fix nullability customizer * fix tests and add annotation check by simple name string * fix and cleanup tests
1 parent 6ac1a28 commit 03d63c3

7 files changed

Lines changed: 365 additions & 143 deletions

File tree

pom.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
34
<modelVersion>4.0.0</modelVersion>
45

56
<parent>

src/main/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizer.java

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
import io.swagger.v3.oas.models.OpenAPI;
44
import io.swagger.v3.oas.models.media.Schema;
5+
import org.jspecify.annotations.NullMarked;
56
import org.springdoc.core.customizers.OpenApiCustomizer;
67

8+
import java.lang.annotation.Annotation;
9+
import java.lang.reflect.AnnotatedType;
710
import java.util.ArrayList;
811
import java.util.Map;
912

13+
@NullMarked
1014
public class NullableCustomizer implements OpenApiCustomizer {
11-
public static final String NULLABLE_MARKER = "NULLABLE";
12-
1315
@Override
1416
@SuppressWarnings("unchecked")
1517
public void customise(OpenAPI openApi) {
@@ -21,57 +23,130 @@ public void customise(OpenAPI openApi) {
2123
var requiredProperties = new ArrayList<String>();
2224
if (((Schema<?>) schema).getProperties() != null) {
2325
var properties = ((Schema<?>) schema).getProperties();
24-
processProperties(properties, requiredProperties);
26+
processProperties(schema.getName(), properties, requiredProperties);
2527
}
2628
if (schema.getAllOf() != null) {
2729
schema.getAllOf().forEach(allOfSchema -> {
2830
var allOfSchemaTyped = (Schema<?>) allOfSchema;
2931
if (allOfSchemaTyped.getProperties() != null) {
3032
var properties = allOfSchemaTyped.getProperties();
31-
processProperties(properties, requiredProperties);
33+
processProperties(schema.getName(), properties, requiredProperties);
3234
}
3335
});
3436
}
3537
schema.setRequired(requiredProperties);
3638
});
3739
}
3840

39-
private static void processProperties(Map<String, Schema> properties, ArrayList<String> requiredProperties) {
41+
@SuppressWarnings("rawtypes")
42+
private static void processProperties(
43+
String modelFqn,
44+
Map<String, Schema> properties,
45+
ArrayList<String> requiredProperties
46+
) {
47+
var cls = loadClass(modelFqn);
48+
if (cls == null) {
49+
return;
50+
}
51+
4052
properties.forEach((propertyName, property) -> {
41-
var isNullable = isNullable(property);
53+
var isNullable = isNullable(cls, propertyName);
4254

4355
if (!isNullable) {
4456
requiredProperties.add(propertyName);
4557
} else {
4658
requiredProperties.remove(propertyName);
4759
}
48-
if (property.getTitle() != null && property.getTitle().equals(NULLABLE_MARKER)) {
49-
property.setTitle(null);
50-
}
51-
if (property.get$ref() != null) {
52-
property.set$ref(property.get$ref().replace(NULLABLE_MARKER, ""));
53-
}
54-
if (property.getItems() != null && property.getItems().get$ref() != null) {
55-
property.getItems().set$ref(property.getItems().get$ref().replace(NULLABLE_MARKER, ""));
56-
}
5760
});
5861
}
5962

60-
private static boolean isNullable(Schema<?> property) {
61-
if (property.getTitle() != null && property.getTitle().equals(NULLABLE_MARKER)) {
62-
return true;
63+
@org.jspecify.annotations.Nullable
64+
private static Class<?> loadClass(String fqn) {
65+
try {
66+
return Class.forName(fqn);
67+
} catch (ClassNotFoundException _) {
68+
// if this does not work, we probably have a parameterized type where the fqn is concatenated
6369
}
6470

65-
if (property.get$ref() != null && property.get$ref().endsWith(NULLABLE_MARKER)) {
66-
return true;
71+
var lastDotIndex = -1;
72+
for (var i = 0; i <= fqn.length(); i++) {
73+
if (i == fqn.length() || fqn.charAt(i) == '.') {
74+
var fullPart = fqn.substring(lastDotIndex + 1, i);
75+
if (!fullPart.isEmpty() && Character.isUpperCase(fullPart.charAt(0))) {
76+
// Try the full part first
77+
var baseFqn = fqn.substring(0, i);
78+
try {
79+
return Class.forName(baseFqn);
80+
} catch (ClassNotFoundException _) {
81+
}
82+
83+
// Try stripping capitalized segments from the end of the part
84+
// e.g., LabelAndDescriptionChoiceCom -> try LabelAndDescriptionChoice, then LabelAndDescription, etc.
85+
for (var j = fullPart.length() - 1; j > 0; j--) {
86+
if (Character.isUpperCase(fullPart.charAt(j))) {
87+
var strippedPart = fullPart.substring(0, j);
88+
var candidateFqn = fqn.substring(0, lastDotIndex + 1) + strippedPart;
89+
try {
90+
return Class.forName(candidateFqn);
91+
} catch (ClassNotFoundException _) {
92+
}
93+
}
94+
}
95+
}
96+
lastDotIndex = i;
97+
}
6798
}
99+
return null;
100+
}
101+
102+
private static boolean isNullable(Class<?> cls, String propertyName) {
103+
var currentClass = cls;
104+
while (currentClass != null) {
105+
try {
106+
var field = currentClass.getDeclaredField(propertyName);
107+
if (isNullable(field.getAnnotatedType(), field.getAnnotations())) {
108+
return true;
109+
}
110+
} catch (NoSuchFieldException _) {
111+
}
112+
113+
for (var method : currentClass.getDeclaredMethods()) {
114+
if (method.getName().equals(propertyName)
115+
|| method.getName().equals("get" + capitalize(propertyName))
116+
|| method.getName().equals("is" + capitalize(propertyName))) {
117+
if (isNullable(method.getAnnotatedReturnType(), method.getAnnotations())) {
118+
return true;
119+
}
120+
}
121+
}
68122

69-
if (property.getItems() != null && property.getItems().get$ref() != null && property.getItems()
70-
.get$ref()
71-
.endsWith("?nullable=true")) {
72-
return true;
123+
currentClass = currentClass.getSuperclass();
73124
}
74125

75126
return false;
76127
}
128+
129+
private static boolean isNullable(
130+
AnnotatedType annotatedType,
131+
Annotation[] annotations
132+
) {
133+
for (var annotation : annotatedType.getAnnotations()) {
134+
if (annotation.annotationType().getSimpleName().equals("Nullable")) {
135+
return true;
136+
}
137+
}
138+
for (var annotation : annotations) {
139+
if (annotation.annotationType().getSimpleName().equals("Nullable")) {
140+
return true;
141+
}
142+
}
143+
return false;
144+
}
145+
146+
private static String capitalize(String str) {
147+
if (str.isEmpty()) {
148+
return str;
149+
}
150+
return str.substring(0, 1).toUpperCase() + str.substring(1);
151+
}
77152
}

src/main/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullablePropertyCustomizer.java

Lines changed: 0 additions & 59 deletions
This file was deleted.

src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
import org.springframework.beans.factory.annotation.Autowired;
1212

1313
import static org.assertj.core.api.Assertions.assertThat;
14-
import static org.junit.jupiter.api.Assertions.assertThrows;
14+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
15+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
1516

1617
@ApplicationTest
1718
public class QueryTransformerTest {
@@ -139,12 +140,10 @@ void givenQueryWithMultipleResults_shouldFail() {
139140

140141
var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q");
141142

142-
assertThrows(
143-
IllegalStateException.class,
144-
() -> QueryTransformer
145-
.of(entityManager, TestModelContainer.class)
146-
.withQuery(query)
147-
.asSingleResult()
143+
assertThatIllegalStateException().isThrownBy(() -> QueryTransformer
144+
.of(entityManager, TestModelContainer.class)
145+
.withQuery(query)
146+
.asSingleResult()
148147
);
149148
}
150149
}
@@ -170,11 +169,10 @@ void givenQueryWithOneResult_shouldPass() {
170169
void givenQueryWithOneResult_shouldFail() {
171170
var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q");
172171

173-
assertThrows(
174-
EntityNotFoundException.class, () -> QueryTransformer
175-
.of(entityManager, TestModelContainer.class)
176-
.withQuery(query)
177-
.asSingleResultOrFail()
172+
assertThatExceptionOfType(EntityNotFoundException.class).isThrownBy(() -> QueryTransformer
173+
.of(entityManager, TestModelContainer.class)
174+
.withQuery(query)
175+
.asSingleResultOrFail()
178176
);
179177
}
180178
}
@@ -220,12 +218,10 @@ void givenQuery_wrongTargetClass_shouldFail() {
220218

221219
var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q");
222220

223-
assertThrows(
224-
TransformerRuntimeException.class,
225-
() -> QueryTransformer
226-
.of(entityManager, WrongContainer.class)
227-
.withQuery(query)
228-
.asList()
221+
assertThatExceptionOfType(TransformerRuntimeException.class).isThrownBy(() -> QueryTransformer
222+
.of(entityManager, WrongContainer.class)
223+
.withQuery(query)
224+
.asList()
229225
);
230226
}
231227
}
@@ -350,12 +346,10 @@ void givenVariousQueries_shouldPassReturningTheRightTotalCount() {
350346
void givenQueryWithSelectDistinct_shouldFail() {
351347
var query = entityManager.createQuery("select distinct q, 'xxx' from QueryTransformerTestModel q");
352348

353-
assertThrows(
354-
IllegalStateException.class,
355-
() -> QueryTransformer
356-
.of(entityManager, TestModelContainer.class)
357-
.withQuery(query)
358-
.asPage(1, 2)
349+
assertThatIllegalStateException().isThrownBy(() -> QueryTransformer
350+
.of(entityManager, TestModelContainer.class)
351+
.withQuery(query)
352+
.asPage(1, 2)
359353
);
360354
}
361355
}

0 commit comments

Comments
 (0)