Skip to content

Commit d8a5906

Browse files
committed
fix nullability customizer
1 parent 4756c5c commit d8a5906

3 files changed

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

0 commit comments

Comments
 (0)