Skip to content

Commit 3043ce4

Browse files
fix: nullable is only set on an object if explicitly configured in a schema annotation
1 parent e2014b0 commit 3043ce4

6 files changed

Lines changed: 505 additions & 5 deletions

File tree

modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ public class ModelResolver extends AbstractModelConverter implements ModelConver
124124
public static boolean composedModelPropertiesAsSibling = System.getProperty(SET_PROPERTY_OF_COMPOSED_MODEL_AS_SIBLING) != null;
125125

126126
private static final int SCHEMA_COMPONENT_PREFIX = "#/components/schemas/".length();
127+
private static final String OBJECT_TYPE = "object";
127128

128129
private static final Predicate<Annotation> ANNOTATIONS_THAT_SHOULD_BE_STRIPPED_FOR_CONTAINER_ITEMS = annotation ->
129130
annotation.annotationType().getName().startsWith("io.swagger") ||
@@ -1108,11 +1109,7 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context
11081109
}
11091110
// check if it has "object" related keywords
11101111
if (isInferredObjectSchema(model) && model.get$ref() == null) {
1111-
if (openapi31 && model.getTypes() == null) {
1112-
model.addType("object");
1113-
} else if (!openapi31 && model.getType() == null) {
1114-
model.type("object");
1115-
}
1112+
setSchemaTypeForObjectSchema(model, resolvedSchemaAnnotation);
11161113
}
11171114
Schema.SchemaResolution resolvedSchemaResolution = AnnotationsUtils.resolveSchemaResolution(this.schemaResolution, resolvedSchemaAnnotation);
11181115

@@ -1150,6 +1147,24 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context
11501147
return model;
11511148
}
11521149

1150+
private void setSchemaTypeForObjectSchema(Schema model, io.swagger.v3.oas.annotations.media.Schema schemaAnnotation) {
1151+
if (openapi31) {
1152+
if (model.getTypes() == null) {
1153+
model.addType(OBJECT_TYPE);
1154+
}
1155+
if (!isNullableSchema(model, schemaAnnotation)) {
1156+
model.setTypes(new LinkedHashSet<>(Collections.singletonList(OBJECT_TYPE)));
1157+
}
1158+
} else {
1159+
if (model.getType() == null) {
1160+
model.type(OBJECT_TYPE);
1161+
}
1162+
if (!isNullableSchema(model, schemaAnnotation)) {
1163+
model.setNullable(null);
1164+
}
1165+
}
1166+
}
1167+
11531168
private Annotation[] addGenericTypeArgumentAnnotationsForOptionalField(BeanPropertyDefinition propDef, Annotation[] annotations) {
11541169

11551170
boolean isNotOptionalType = Optional.ofNullable(propDef)
@@ -3469,6 +3484,25 @@ public void setConfiguration(Configuration configuration) {
34693484
}
34703485
}
34713486

3487+
/**
3488+
* Currently {@code null} is not a valid type for any {@code object} other than {@code AdditionalProperties}.
3489+
* This since the resolver currently does not produce proper nullable ref:s
3490+
* @param schema The schema that should be classified
3491+
* @param schemaAnnotation The schema annotation
3492+
* @return Whether the schema is considered valid for having the {@code null} type
3493+
*/
3494+
private boolean isNullableSchema(Schema schema, io.swagger.v3.oas.annotations.media.Schema schemaAnnotation) {
3495+
if (openapi31) {
3496+
return isObjectSchema(schema) && schema.getAdditionalProperties() != null;
3497+
} else {
3498+
// If the schema annotation has explicitly set nullable to true, then keep that setting
3499+
if (schemaAnnotation != null && schemaAnnotation.nullable()) {
3500+
return true;
3501+
}
3502+
return isObjectSchema(schema) && schema.getAdditionalProperties() != null;
3503+
}
3504+
}
3505+
34723506
protected boolean isObjectSchema(Schema schema) {
34733507
return SchemaTypeUtils.isObjectSchema(schema);
34743508
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package io.swagger.v3.core.issues;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import io.swagger.v3.core.converter.ModelConverters;
6+
import io.swagger.v3.core.converter.ResolvedSchema;
7+
import io.swagger.v3.core.util.Json;
8+
import io.swagger.v3.core.util.Json31;
9+
import io.swagger.v3.core.util.ResourceUtils;
10+
import io.swagger.v3.oas.annotations.media.Schema;
11+
import org.testng.annotations.Test;
12+
13+
import javax.annotation.Nullable;
14+
import java.io.IOException;
15+
16+
import static org.testng.Assert.assertEquals;
17+
18+
/**
19+
* Reproduces GitHub Issue #5115
20+
* Issue using Nullable annotation on Model-fields
21+
*
22+
* <p>Tests that @Nullable annotation does not affect an object in an invalid manner. Any nullability that is configured
23+
* with a schema annotation (nullable = true for OAS30 and null in types for OAS31) is retained.
24+
*
25+
* <p>Note: This test uses javax.annotation.Nullable which is automatically transformed to
26+
* jakarta.annotation.Nullable in the swagger-core-jakarta module via the Eclipse Transformer.
27+
*/
28+
public class Issue5115Test {
29+
30+
@Test
31+
public void testObjectDoesNotGetInvalidNullableSchemaOAS31() throws IOException {
32+
ResolvedSchema schema = ModelConverters.getInstance(true)
33+
.readAllAsResolvedSchema(ModelWithObject.class);
34+
35+
String expectedJson = ResourceUtils.loadClassResource(getClass(), "specFiles/NullableObjectFieldsOAS31.json");
36+
String actualJson = Json31.pretty(schema);
37+
ObjectMapper mapper = new ObjectMapper();
38+
JsonNode expectedNode = mapper.readTree(expectedJson);
39+
JsonNode actualNode = mapper.readTree(actualJson);
40+
assertEquals(actualNode, expectedNode);
41+
}
42+
43+
@Test
44+
public void testObjectDoesNotGetInvalidNullableSchemaOAS30() throws IOException {
45+
ResolvedSchema schema = ModelConverters.getInstance()
46+
.readAllAsResolvedSchema(ModelWithObject.class);
47+
48+
String expectedJson = ResourceUtils.loadClassResource(getClass(), "specFiles/NullableObjectFieldsOAS30.json");
49+
String actualJson = Json.pretty(schema);
50+
ObjectMapper mapper = new ObjectMapper();
51+
JsonNode expectedNode = mapper.readTree(expectedJson);
52+
JsonNode actualNode = mapper.readTree(actualJson);
53+
assertEquals(actualNode, expectedNode);
54+
}
55+
56+
@Test
57+
public void testObjectKeepsInvalidNullableSchemaIfSetInSchemaAnnotationOAS31() throws IOException {
58+
ResolvedSchema schema = ModelConverters.getInstance(true)
59+
.readAllAsResolvedSchema(ModelWithObjectThatHasNullableInSchemaAnnotationOAS31.class);
60+
61+
String expectedJson = ResourceUtils.loadClassResource(getClass(), "specFiles/NullableObjectFieldsSchemaAnnotationOAS31.json");
62+
String actualJson = Json31.pretty(schema);
63+
ObjectMapper mapper = new ObjectMapper();
64+
JsonNode expectedNode = mapper.readTree(expectedJson);
65+
JsonNode actualNode = mapper.readTree(actualJson);
66+
assertEquals(actualNode, expectedNode);
67+
}
68+
69+
@Test
70+
public void testObjectKeepsInvalidNullableSchemaIfSetInSchemaAnnotationOAS30() throws IOException {
71+
ResolvedSchema schema = ModelConverters.getInstance()
72+
.readAllAsResolvedSchema(ModelWithObjectThatHasNullableInSchemaAnnotationOAS30.class);
73+
74+
String expectedJson = ResourceUtils.loadClassResource(getClass(), "specFiles/NullableObjectFieldsSchemaAnnotationOAS30.json");
75+
String actualJson = Json.pretty(schema);
76+
ObjectMapper mapper = new ObjectMapper();
77+
JsonNode expectedNode = mapper.readTree(expectedJson);
78+
JsonNode actualNode = mapper.readTree(actualJson);
79+
assertEquals(actualNode, expectedNode);
80+
}
81+
82+
public static class ModelWithObject {
83+
84+
@Nullable
85+
private Model nullableModel;
86+
87+
private Model model;
88+
89+
@Nullable
90+
public Model getNullableModel() {
91+
return nullableModel;
92+
}
93+
94+
public void setNullableModel(@Nullable Model model) {
95+
this.nullableModel = model;
96+
}
97+
98+
public Model getModel() {
99+
return model;
100+
}
101+
102+
public void setModel(Model model) {
103+
this.model = model;
104+
}
105+
}
106+
107+
public static class ModelWithObjectThatHasNullableInSchemaAnnotationOAS30 {
108+
109+
// This nullable is lost since the fields are read top to bottom and model does not define nullable
110+
@Schema(nullable = true)
111+
private NestedModel nullableModel;
112+
113+
private NestedModel model;
114+
115+
@Schema(nullable = true)
116+
private NestedModel2 nullableModel2;
117+
118+
public NestedModel getNullableModel() {
119+
return nullableModel;
120+
}
121+
122+
public void setNullableModel(NestedModel model) {
123+
this.nullableModel = model;
124+
}
125+
126+
public NestedModel getModel() {
127+
return model;
128+
}
129+
130+
public void setModel(NestedModel model) {
131+
this.model = model;
132+
}
133+
134+
public NestedModel2 getNullableModel2() {
135+
return nullableModel2;
136+
}
137+
138+
public void setNullableModel2(NestedModel2 nullableModel2) {
139+
this.nullableModel2 = nullableModel2;
140+
}
141+
}
142+
143+
public static class ModelWithObjectThatHasNullableInSchemaAnnotationOAS31 {
144+
145+
@Schema(types = {"object", "null"})
146+
private Model nullableModel;
147+
148+
private Model model;
149+
150+
public Model getNullableModel() {
151+
return nullableModel;
152+
}
153+
154+
public void setNullableModel(Model model) {
155+
this.nullableModel = model;
156+
}
157+
158+
public Model getModel() {
159+
return model;
160+
}
161+
162+
public void setModel(Model model) {
163+
this.model = model;
164+
}
165+
}
166+
167+
public static class Model {
168+
169+
private NestedModel nestedModel;
170+
171+
@Nullable
172+
private NestedModel nullableNestedModel;
173+
174+
@Nullable
175+
private NestedModel2 nullableNestedModel2;
176+
177+
private NestedModel2 nestedModel2;
178+
179+
public NestedModel getNestedModel() {
180+
return nestedModel;
181+
}
182+
183+
public void setNestedModel(NestedModel nestedModel) {
184+
this.nestedModel = nestedModel;
185+
}
186+
187+
@Nullable
188+
public NestedModel getNullableNestedModel() {
189+
return nullableNestedModel;
190+
}
191+
192+
public void setNullableNestedModel(@Nullable NestedModel nullableNestedModel) {
193+
this.nullableNestedModel = nullableNestedModel;
194+
}
195+
196+
@Nullable
197+
public NestedModel2 getNullableNestedModel2() {
198+
return nullableNestedModel2;
199+
}
200+
201+
public void setNullableNestedModel2(@Nullable NestedModel2 nullableNestedModel2) {
202+
this.nullableNestedModel2 = nullableNestedModel2;
203+
}
204+
205+
public NestedModel2 getNestedModel2() {
206+
return nestedModel2;
207+
}
208+
209+
public void setNestedModel2(NestedModel2 nestedModel2) {
210+
this.nestedModel2 = nestedModel2;
211+
}
212+
}
213+
214+
public static class NestedModel {
215+
private String field;
216+
217+
public String getField() {
218+
return field;
219+
}
220+
221+
public void setField(String field) {
222+
this.field = field;
223+
}
224+
}
225+
226+
public static class NestedModel2 {
227+
private String field2;
228+
229+
public String getField2() {
230+
return field2;
231+
}
232+
233+
public void setField2(String field2) {
234+
this.field2 = field2;
235+
}
236+
}
237+
238+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema" : {
3+
"type" : "object",
4+
"properties" : {
5+
"nullableModel" : {
6+
"$ref" : "#/components/schemas/Model"
7+
},
8+
"model" : {
9+
"$ref" : "#/components/schemas/Model"
10+
}
11+
}
12+
},
13+
"referencedSchemas" : {
14+
"Model" : {
15+
"type" : "object",
16+
"properties" : {
17+
"nestedModel" : {
18+
"$ref" : "#/components/schemas/NestedModel"
19+
},
20+
"nullableNestedModel" : {
21+
"$ref" : "#/components/schemas/NestedModel"
22+
},
23+
"nullableNestedModel2" : {
24+
"$ref" : "#/components/schemas/NestedModel2"
25+
},
26+
"nestedModel2" : {
27+
"$ref" : "#/components/schemas/NestedModel2"
28+
}
29+
}
30+
},
31+
"ModelWithObject" : {
32+
"type" : "object",
33+
"properties" : {
34+
"nullableModel" : {
35+
"$ref" : "#/components/schemas/Model"
36+
},
37+
"model" : {
38+
"$ref" : "#/components/schemas/Model"
39+
}
40+
}
41+
},
42+
"NestedModel" : {
43+
"type" : "object",
44+
"properties" : {
45+
"field" : {
46+
"type" : "string"
47+
}
48+
}
49+
},
50+
"NestedModel2" : {
51+
"type" : "object",
52+
"properties" : {
53+
"field2" : {
54+
"type" : "string"
55+
}
56+
}
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)