55import org .jspecify .annotations .NullMarked ;
66import org .springdoc .core .customizers .OpenApiCustomizer ;
77
8+ import java .lang .annotation .Annotation ;
9+ import java .lang .reflect .AnnotatedType ;
810import java .util .ArrayList ;
911import java .util .Map ;
1012
1113@ NullMarked
1214public 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}
0 commit comments