Skip to content

Commit 298869e

Browse files
committed
fix nullability customizer
1 parent 4756c5c commit 298869e

3 files changed

Lines changed: 254 additions & 85 deletions

File tree

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

Lines changed: 96 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
import org.jspecify.annotations.NullMarked;
66
import org.springdoc.core.customizers.OpenApiCustomizer;
77

8+
import java.lang.annotation.Annotation;
9+
import java.lang.reflect.AnnotatedType;
810
import java.util.ArrayList;
911
import java.util.Map;
1012

1113
@NullMarked
1214
public class NullableCustomizer implements OpenApiCustomizer {
13-
public static final String NULLABLE_MARKER = "NULLABLE";
14-
1515
@Override
1616
@SuppressWarnings("unchecked")
1717
public void customise(OpenAPI openApi) {
@@ -23,57 +23,129 @@ public void customise(OpenAPI openApi) {
2323
var requiredProperties = new ArrayList<String>();
2424
if (((Schema<?>) schema).getProperties() != null) {
2525
var properties = ((Schema<?>) schema).getProperties();
26-
processProperties(properties, requiredProperties);
26+
processProperties(schema.getName(), properties, requiredProperties);
2727
}
2828
if (schema.getAllOf() != null) {
2929
schema.getAllOf().forEach(allOfSchema -> {
3030
var allOfSchemaTyped = (Schema<?>) allOfSchema;
3131
if (allOfSchemaTyped.getProperties() != null) {
3232
var properties = allOfSchemaTyped.getProperties();
33-
processProperties(properties, requiredProperties);
33+
processProperties(schema.getName(), properties, requiredProperties);
3434
}
3535
});
3636
}
3737
schema.setRequired(requiredProperties);
3838
});
3939
}
4040

41-
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+
4252
properties.forEach((propertyName, property) -> {
43-
var isNullable = isNullable(property);
53+
var isNullable = isNullable(cls, propertyName);
4454

4555
if (!isNullable) {
4656
requiredProperties.add(propertyName);
4757
} else {
4858
requiredProperties.remove(propertyName);
4959
}
50-
if (property.getTitle() != null && property.getTitle().equals(NULLABLE_MARKER)) {
51-
property.setTitle(null);
52-
}
53-
if (property.get$ref() != null) {
54-
property.set$ref(property.get$ref().replace(NULLABLE_MARKER, ""));
55-
}
56-
if (property.getItems() != null && property.getItems().get$ref() != null) {
57-
property.getItems().set$ref(property.getItems().get$ref().replace(NULLABLE_MARKER, ""));
58-
}
5960
});
6061
}
6162

62-
private static boolean isNullable(Schema<?> property) {
63-
if (property.getTitle() != null && property.getTitle().equals(NULLABLE_MARKER)) {
64-
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
6569
}
6670

67-
if (property.get$ref() != null && property.get$ref().endsWith(NULLABLE_MARKER)) {
68-
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+
}
6998
}
99+
return null;
100+
}
70101

71-
if (property.getItems() != null && property.getItems().get$ref() != null && property.getItems()
72-
.get$ref()
73-
.endsWith(NULLABLE_MARKER)) {
74-
return true;
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+
}
122+
123+
currentClass = currentClass.getSuperclass();
75124
}
76125

77126
return false;
78127
}
128+
129+
private static boolean isNullable(
130+
AnnotatedType annotatedType,
131+
Annotation[] annotations
132+
) {
133+
if (annotatedType.isAnnotationPresent(org.jspecify.annotations.Nullable.class)) {
134+
return true;
135+
}
136+
for (var annotation : annotations) {
137+
var name = annotation.annotationType().getName();
138+
if (name.equals("org.springframework.lang.Nullable") || name.equals("jakarta.annotation.Nullable")) {
139+
return true;
140+
}
141+
}
142+
return false;
143+
}
144+
145+
private static String capitalize(String str) {
146+
if (str.isEmpty()) {
147+
return str;
148+
}
149+
return str.substring(0, 1).toUpperCase() + str.substring(1);
150+
}
79151
}

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

Lines changed: 0 additions & 61 deletions
This file was deleted.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package it.aboutbits.springboot.toolbox.swagger.customization.default_not_null;
2+
3+
import io.swagger.v3.oas.models.Components;
4+
import io.swagger.v3.oas.models.OpenAPI;
5+
import io.swagger.v3.oas.models.media.Schema;
6+
import io.swagger.v3.oas.models.media.StringSchema;
7+
import org.jspecify.annotations.NullUnmarked;
8+
import org.jspecify.annotations.Nullable;
9+
import org.junit.jupiter.api.Test;
10+
11+
import java.util.List;
12+
13+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
14+
import static org.junit.jupiter.api.Assertions.assertTrue;
15+
16+
@NullUnmarked
17+
class NullableCustomizerTest {
18+
19+
public static class BaseClass {
20+
@Nullable
21+
private String baseField;
22+
23+
public String getBaseField() {
24+
return baseField;
25+
}
26+
}
27+
28+
public static class SubClass extends BaseClass {
29+
private String subField;
30+
31+
public String getSubField() {
32+
return subField;
33+
}
34+
}
35+
36+
public static class MethodAnnotated {
37+
private String annotatedGetter;
38+
39+
@Nullable
40+
public String getAnnotatedGetter() {
41+
return annotatedGetter;
42+
}
43+
}
44+
45+
public static class DirectMethodAnnotated {
46+
private String directMethod;
47+
48+
@Nullable
49+
public String directMethod() {
50+
return directMethod;
51+
}
52+
}
53+
54+
@Test
55+
void shouldFindFieldInSuperClass() {
56+
var customizer = new NullableCustomizer();
57+
var openApi = new OpenAPI();
58+
var components = new Components();
59+
60+
var subClassSchema = new Schema<Object>();
61+
subClassSchema.setName(SubClass.class.getName());
62+
subClassSchema.addProperty("baseField", new StringSchema());
63+
subClassSchema.addProperty("subField", new StringSchema());
64+
65+
components.addSchemas(SubClass.class.getName(), subClassSchema);
66+
openApi.setComponents(components);
67+
68+
assertDoesNotThrow(() -> customizer.customise(openApi));
69+
70+
List<String> required = subClassSchema.getRequired();
71+
assertTrue(required != null && required.contains("subField"), "subField should be required");
72+
assertTrue(required == null || !required.contains("baseField"), "baseField should NOT be required");
73+
}
74+
75+
@Test
76+
void shouldFindAnnotationOnGetter() {
77+
var customizer = new NullableCustomizer();
78+
var openApi = new OpenAPI();
79+
var components = new Components();
80+
81+
var schema = new Schema<Object>();
82+
schema.setName(MethodAnnotated.class.getName());
83+
schema.addProperty("annotatedGetter", new StringSchema());
84+
85+
components.addSchemas(MethodAnnotated.class.getName(), schema);
86+
openApi.setComponents(components);
87+
88+
assertDoesNotThrow(() -> customizer.customise(openApi));
89+
90+
List<String> required = schema.getRequired();
91+
assertTrue(required == null || !required.contains("annotatedGetter"), "annotatedGetter should NOT be required");
92+
}
93+
94+
@Test
95+
void shouldFindAnnotationOnDirectMethod() {
96+
var customizer = new NullableCustomizer();
97+
var openApi = new OpenAPI();
98+
var components = new Components();
99+
100+
var schema = new Schema<Object>();
101+
schema.setName(DirectMethodAnnotated.class.getName());
102+
schema.addProperty("directMethod", new StringSchema());
103+
104+
components.addSchemas(DirectMethodAnnotated.class.getName(), schema);
105+
openApi.setComponents(components);
106+
107+
assertDoesNotThrow(() -> customizer.customise(openApi));
108+
109+
List<String> required = schema.getRequired();
110+
assertTrue(required == null || !required.contains("directMethod"), "directMethod should NOT be required");
111+
}
112+
113+
@Test
114+
void shouldHandleConcatenatedFqns() {
115+
var customizer = new NullableCustomizer();
116+
var openApi = new OpenAPI();
117+
var components = new Components();
118+
119+
var schema = new Schema<Object>();
120+
// Simulating the concatenated FQN pattern described in the issue
121+
var concatenatedFqn = SubClass.class.getName() + "It.aboutbits.something";
122+
schema.setName(concatenatedFqn);
123+
schema.addProperty("baseField", new StringSchema());
124+
125+
components.addSchemas(concatenatedFqn, schema);
126+
openApi.setComponents(components);
127+
128+
assertDoesNotThrow(() -> customizer.customise(openApi));
129+
130+
List<String> required = schema.getRequired();
131+
assertTrue(
132+
required == null || !required.contains("baseField"),
133+
"baseField should NOT be required even with concatenated FQN"
134+
);
135+
}
136+
137+
@Test
138+
void shouldNotThrowWhenFieldNotFound() {
139+
var customizer = new NullableCustomizer();
140+
var openApi = new OpenAPI();
141+
var components = new Components();
142+
143+
var schema = new Schema<Object>();
144+
schema.setName(SubClass.class.getName());
145+
schema.addProperty("nonExistent", new StringSchema());
146+
147+
components.addSchemas(SubClass.class.getName(), schema);
148+
openApi.setComponents(components);
149+
150+
assertDoesNotThrow(() -> customizer.customise(openApi));
151+
152+
List<String> required = schema.getRequired();
153+
assertTrue(
154+
required != null && required.contains("nonExistent"),
155+
"nonExistent field should be considered required if not found and not nullable"
156+
);
157+
}
158+
}

0 commit comments

Comments
 (0)