Skip to content

Commit c64c87a

Browse files
committed
fix swagger nullability for collections
1 parent 5f39e18 commit c64c87a

4 files changed

Lines changed: 306 additions & 0 deletions

File tree

src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMeta.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@ public class SwaggerMeta {
3434

3535
@Nullable
3636
private String mapKeyTypeFqn = null;
37+
38+
@Nullable
39+
@JsonProperty("isNullable")
40+
private Boolean isNullable = null;
3741
}

src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMetaUtil.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ public static String setIsNestedStructure(@Nullable String currentMeta, boolean
6363
return OBJECT_MAPPER.writeValueAsString(meta);
6464
}
6565

66+
@SneakyThrows(JsonProcessingException.class)
67+
public static String setIsNullable(@Nullable String currentMeta, boolean value) {
68+
var meta = getSwaggerMeta(currentMeta);
69+
meta.setIsNullable(!value ? null : true);
70+
71+
return OBJECT_MAPPER.writeValueAsString(meta);
72+
}
73+
6674
private static SwaggerMeta getSwaggerMeta(@Nullable String currentMeta) {
6775
var meta = new SwaggerMeta();
6876
if (currentMeta != null) {

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22

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

89
import java.lang.annotation.Annotation;
10+
import java.lang.reflect.AnnotatedArrayType;
11+
import java.lang.reflect.AnnotatedParameterizedType;
912
import java.lang.reflect.AnnotatedType;
13+
import java.lang.reflect.ParameterizedType;
1014
import java.util.ArrayList;
15+
import java.util.Collection;
1116
import java.util.Map;
1217

1318
@NullMarked
@@ -57,9 +62,98 @@ private static void processProperties(
5762
} else {
5863
requiredProperties.remove(propertyName);
5964
}
65+
66+
// Check for nullable type parameters in collections/arrays
67+
var annotatedType = getAnnotatedType(cls, propertyName);
68+
if (annotatedType != null) {
69+
var nullableDepths = new ArrayList<Integer>();
70+
findNullableDepths(annotatedType, 0, nullableDepths);
71+
// Add "nullable" description at each depth where nullable elements are found
72+
for (var depth : nullableDepths) {
73+
addNullableDescriptionAtDepth(property, depth);
74+
}
75+
}
6076
});
6177
}
6278

79+
@org.jspecify.annotations.Nullable
80+
private static AnnotatedType getAnnotatedType(Class<?> cls, String propertyName) {
81+
var currentClass = cls;
82+
while (currentClass != null) {
83+
try {
84+
var field = currentClass.getDeclaredField(propertyName);
85+
return field.getAnnotatedType();
86+
} catch (NoSuchFieldException _) {
87+
}
88+
89+
for (var method : currentClass.getDeclaredMethods()) {
90+
if (method.getName().equals(propertyName)
91+
|| method.getName().equals("get" + capitalize(propertyName))
92+
|| method.getName().equals("is" + capitalize(propertyName))) {
93+
return method.getAnnotatedReturnType();
94+
}
95+
}
96+
97+
currentClass = currentClass.getSuperclass();
98+
}
99+
return null;
100+
}
101+
102+
private static void findNullableDepths(AnnotatedType annotatedType, int depth, ArrayList<Integer> nullableDepths) {
103+
if (annotatedType instanceof AnnotatedParameterizedType parameterizedType) {
104+
var rawType = parameterizedType.getType();
105+
if (rawType instanceof ParameterizedType pt) {
106+
var rawClass = pt.getRawType();
107+
if (rawClass instanceof Class<?> clazz && isCollectionType(clazz)) {
108+
var typeArgs = parameterizedType.getAnnotatedActualTypeArguments();
109+
for (var typeArg : typeArgs) {
110+
if (hasNullableAnnotation(typeArg)) {
111+
nullableDepths.add(depth);
112+
}
113+
// Recursively check nested type parameters
114+
findNullableDepths(typeArg, depth + 1, nullableDepths);
115+
}
116+
}
117+
}
118+
} else if (annotatedType instanceof AnnotatedArrayType arrayType) {
119+
var componentType = arrayType.getAnnotatedGenericComponentType();
120+
if (hasNullableAnnotation(componentType)) {
121+
nullableDepths.add(depth);
122+
}
123+
// Recursively check nested array types
124+
findNullableDepths(componentType, depth + 1, nullableDepths);
125+
}
126+
}
127+
128+
@SuppressWarnings("rawtypes")
129+
private static void addNullableDescriptionAtDepth(Schema<?> schema, int depth) {
130+
Schema currentSchema = schema;
131+
for (int i = 0; i <= depth; i++) {
132+
var items = currentSchema.getItems();
133+
if (items == null) {
134+
return; // Schema structure doesn't match expected depth
135+
}
136+
currentSchema = items;
137+
}
138+
currentSchema.setDescription(SwaggerMetaUtil.setIsNullable(
139+
currentSchema.getDescription(),
140+
true
141+
));
142+
}
143+
144+
private static boolean isCollectionType(Class<?> clazz) {
145+
return Collection.class.isAssignableFrom(clazz) || clazz.isArray();
146+
}
147+
148+
private static boolean hasNullableAnnotation(AnnotatedType annotatedType) {
149+
for (var annotation : annotatedType.getAnnotations()) {
150+
if (annotation.annotationType().getSimpleName().equals("Nullable")) {
151+
return true;
152+
}
153+
}
154+
return false;
155+
}
156+
63157
@org.jspecify.annotations.Nullable
64158
private static Class<?> loadClass(String fqn) {
65159
try {

src/test/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizerTest.java

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.swagger.v3.oas.models.Components;
44
import io.swagger.v3.oas.models.OpenAPI;
5+
import io.swagger.v3.oas.models.media.ArraySchema;
56
import io.swagger.v3.oas.models.media.Schema;
67
import io.swagger.v3.oas.models.media.StringSchema;
78
import org.jspecify.annotations.NullUnmarked;
@@ -10,6 +11,8 @@
1011

1112
import java.lang.annotation.Retention;
1213
import java.lang.annotation.RetentionPolicy;
14+
import java.util.List;
15+
import java.util.Set;
1316

1417
import static org.assertj.core.api.Assertions.assertThat;
1518

@@ -51,6 +54,47 @@ public String directMethod() {
5154
}
5255
}
5356

57+
// Test classes for nullable type parameters in collections
58+
public static class ListWithNullableElements {
59+
private List<@Nullable String> items;
60+
61+
public List<@Nullable String> getItems() {
62+
return items;
63+
}
64+
}
65+
66+
public static class SetWithNullableElements {
67+
private Set<@Nullable String> items;
68+
69+
public Set<@Nullable String> getItems() {
70+
return items;
71+
}
72+
}
73+
74+
public static class NestedListWithNullableElements {
75+
private List<List<@Nullable String>> nestedItems;
76+
77+
public List<List<@Nullable String>> getNestedItems() {
78+
return nestedItems;
79+
}
80+
}
81+
82+
public static class ArrayWithNullableElements {
83+
private @Nullable String[] items;
84+
85+
public @Nullable String[] getItems() {
86+
return items;
87+
}
88+
}
89+
90+
public static class ListWithNonNullableElements {
91+
private List<String> items;
92+
93+
public List<String> getItems() {
94+
return items;
95+
}
96+
}
97+
5498
@Test
5599
void shouldFindFieldInSuperClass() {
56100
// given
@@ -189,6 +233,162 @@ void shouldFindCustomNullableAnnotation() {
189233
assertThat(required).as("customNullableField should NOT be required").isNullOrEmpty();
190234
}
191235

236+
@Test
237+
void shouldAddDescriptionForListWithNullableElements() {
238+
// given
239+
var customizer = new NullableCustomizer();
240+
var openApi = new OpenAPI();
241+
var components = new Components();
242+
243+
var schema = new Schema<Object>();
244+
schema.setName(ListWithNullableElements.class.getName());
245+
var itemsProperty = new ArraySchema();
246+
itemsProperty.setItems(new StringSchema());
247+
schema.addProperty("items", itemsProperty);
248+
249+
components.addSchemas(ListWithNullableElements.class.getName(), schema);
250+
openApi.setComponents(components);
251+
252+
// when
253+
customizer.customise(openApi);
254+
255+
// then
256+
var property = (ArraySchema) schema.getProperties().get("items");
257+
assertThat(property.getItems().getDescription()).as("description should indicate nullable elements")
258+
.isEqualTo("{\"isNullable\":true}");
259+
}
260+
261+
@Test
262+
void shouldAddDescriptionForSetWithNullableElements() {
263+
// given
264+
var customizer = new NullableCustomizer();
265+
var openApi = new OpenAPI();
266+
var components = new Components();
267+
268+
var schema = new Schema<Object>();
269+
schema.setName(SetWithNullableElements.class.getName());
270+
var itemsProperty = new ArraySchema();
271+
itemsProperty.setItems(new StringSchema());
272+
schema.addProperty("items", itemsProperty);
273+
274+
components.addSchemas(SetWithNullableElements.class.getName(), schema);
275+
openApi.setComponents(components);
276+
277+
// when
278+
customizer.customise(openApi);
279+
280+
// then
281+
var property = (ArraySchema) schema.getProperties().get("items");
282+
assertThat(property.getItems().getDescription()).as("description should indicate nullable elements")
283+
.isEqualTo("{\"isNullable\":true}");
284+
}
285+
286+
@Test
287+
void shouldAddDescriptionForNestedListWithNullableElements() {
288+
// given
289+
var customizer = new NullableCustomizer();
290+
var openApi = new OpenAPI();
291+
var components = new Components();
292+
293+
var schema = new Schema<Object>();
294+
schema.setName(NestedListWithNullableElements.class.getName());
295+
var nestedItemsProperty = new ArraySchema();
296+
var innerArray = new ArraySchema();
297+
innerArray.setItems(new StringSchema());
298+
nestedItemsProperty.setItems(innerArray);
299+
schema.addProperty("nestedItems", nestedItemsProperty);
300+
301+
components.addSchemas(NestedListWithNullableElements.class.getName(), schema);
302+
openApi.setComponents(components);
303+
304+
// when
305+
customizer.customise(openApi);
306+
307+
// then
308+
var property = (ArraySchema) schema.getProperties().get("nestedItems");
309+
var innerItems = (ArraySchema) property.getItems();
310+
assertThat(innerItems.getItems().getDescription()).as("description should indicate nested nullable elements")
311+
.isEqualTo("{\"isNullable\":true}");
312+
}
313+
314+
@Test
315+
void shouldAddDescriptionForArrayWithNullableElements() {
316+
// given
317+
var customizer = new NullableCustomizer();
318+
var openApi = new OpenAPI();
319+
var components = new Components();
320+
321+
var schema = new Schema<Object>();
322+
schema.setName(ArrayWithNullableElements.class.getName());
323+
var itemsProperty = new ArraySchema();
324+
itemsProperty.setItems(new StringSchema());
325+
schema.addProperty("items", itemsProperty);
326+
327+
components.addSchemas(ArrayWithNullableElements.class.getName(), schema);
328+
openApi.setComponents(components);
329+
330+
// when
331+
customizer.customise(openApi);
332+
333+
// then
334+
var property = (ArraySchema) schema.getProperties().get("items");
335+
assertThat(property.getItems().getDescription()).as("description should indicate nullable elements")
336+
.isEqualTo("{\"isNullable\":true}");
337+
}
338+
339+
@Test
340+
void shouldNotAddDescriptionForListWithNonNullableElements() {
341+
// given
342+
var customizer = new NullableCustomizer();
343+
var openApi = new OpenAPI();
344+
var components = new Components();
345+
346+
var schema = new Schema<Object>();
347+
schema.setName(ListWithNonNullableElements.class.getName());
348+
var itemsProperty = new ArraySchema();
349+
itemsProperty.setItems(new StringSchema());
350+
schema.addProperty("items", itemsProperty);
351+
352+
components.addSchemas(ListWithNonNullableElements.class.getName(), schema);
353+
openApi.setComponents(components);
354+
355+
// when
356+
customizer.customise(openApi);
357+
358+
// then
359+
var property = schema.getProperties().get("items");
360+
assertThat(property.getDescription()).as("description should be null for non-nullable elements")
361+
.isNull();
362+
}
363+
364+
@Test
365+
void shouldAppendToExistingDescription() {
366+
// given
367+
var customizer = new NullableCustomizer();
368+
var openApi = new OpenAPI();
369+
var components = new Components();
370+
371+
var schema = new Schema<Object>();
372+
schema.setName(ListWithNullableElements.class.getName());
373+
var itemsProperty = new ArraySchema();
374+
var itemsSchema = new StringSchema();
375+
itemsSchema.setDescription("{\"isCustomType\":true}");
376+
itemsProperty.setItems(itemsSchema);
377+
schema.addProperty("items", itemsProperty);
378+
379+
components.addSchemas(ListWithNullableElements.class.getName(), schema);
380+
openApi.setComponents(components);
381+
382+
// when
383+
customizer.customise(openApi);
384+
385+
// then
386+
var property = (ArraySchema) schema.getProperties().get("items");
387+
assertThat(property.getItems().getDescription()).as(
388+
"description should append nullable info to existing description")
389+
.isEqualTo("{\"isCustomType\":true,\"isNullable\":true}");
390+
}
391+
192392
public static class Nest {
193393
@Retention(RetentionPolicy.RUNTIME)
194394
public @interface Nullable {

0 commit comments

Comments
 (0)