22
33import io .swagger .v3 .oas .models .OpenAPI ;
44import io .swagger .v3 .oas .models .media .Schema ;
5+ import org .jspecify .annotations .NullMarked ;
56import org .springdoc .core .customizers .OpenApiCustomizer ;
67
8+ import java .lang .annotation .Annotation ;
9+ import java .lang .reflect .AnnotatedType ;
710import java .util .ArrayList ;
811import java .util .Map ;
912
13+ @ NullMarked
1014public 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,129 @@ 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+ }
68101
69- if (property .getItems () != null && property .getItems ().get$ref () != null && property .getItems ()
70- .get$ref ()
71- .endsWith ("?nullable=true" )) {
72- 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 ();
73124 }
74125
75126 return false ;
76127 }
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+ }
77151}
0 commit comments