From e2014b03d0930cd5d2456888b4190dd6a3f63fd5 Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:00:33 +0200 Subject: [PATCH 1/2] refactor: annotation parsing as methods --- .../v3/core/jackson/ModelResolver.java | 60 +++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java index fc6e5d7c38..5a84e7fcaa 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java @@ -179,19 +179,9 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context } final Annotation resolvedSchemaOrArrayAnnotation = AnnotationsUtils.mergeSchemaAnnotations(annotatedType.getCtxAnnotations(), type); - final io.swagger.v3.oas.annotations.media.Schema resolvedSchemaAnnotation = - resolvedSchemaOrArrayAnnotation == null ? - null : - resolvedSchemaOrArrayAnnotation instanceof io.swagger.v3.oas.annotations.media.ArraySchema ? - ((io.swagger.v3.oas.annotations.media.ArraySchema) resolvedSchemaOrArrayAnnotation).schema() : - (io.swagger.v3.oas.annotations.media.Schema) resolvedSchemaOrArrayAnnotation; - - final io.swagger.v3.oas.annotations.media.ArraySchema resolvedArrayAnnotation = - resolvedSchemaOrArrayAnnotation == null ? - null : - resolvedSchemaOrArrayAnnotation instanceof io.swagger.v3.oas.annotations.media.ArraySchema ? - (io.swagger.v3.oas.annotations.media.ArraySchema) resolvedSchemaOrArrayAnnotation : - null; + final io.swagger.v3.oas.annotations.media.Schema resolvedSchemaAnnotation = getSchemaAnnotation(resolvedSchemaOrArrayAnnotation); + + final io.swagger.v3.oas.annotations.media.ArraySchema resolvedArrayAnnotation = getArraySchemaAnnotation(resolvedSchemaOrArrayAnnotation); final BeanDescription beanDesc; { @@ -744,12 +734,7 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context propName = propSchemaName; } Annotation propSchemaOrArray = AnnotationsUtils.mergeSchemaAnnotations(annotations, propType); - final io.swagger.v3.oas.annotations.media.Schema propResolvedSchemaAnnotation = - propSchemaOrArray == null ? - null : - propSchemaOrArray instanceof io.swagger.v3.oas.annotations.media.ArraySchema ? - ((io.swagger.v3.oas.annotations.media.ArraySchema) propSchemaOrArray).arraySchema() : - (io.swagger.v3.oas.annotations.media.Schema) propSchemaOrArray; + final io.swagger.v3.oas.annotations.media.Schema propResolvedSchemaAnnotation = getSchemaAnnotationForArray(propSchemaOrArray); io.swagger.v3.oas.annotations.media.Schema.AccessMode accessMode = resolveAccessMode(propDef, type, propResolvedSchemaAnnotation); io.swagger.v3.oas.annotations.media.Schema.RequiredMode requiredMode = resolveRequiredMode(propResolvedSchemaAnnotation, propType); @@ -3040,12 +3025,7 @@ protected void resolveSchemaMembers(Schema schema, AnnotatedType annotatedType, } final Annotation resolvedSchemaOrArrayAnnotation = AnnotationsUtils.mergeSchemaAnnotations(annotatedType.getCtxAnnotations(), type); - final io.swagger.v3.oas.annotations.media.Schema schemaAnnotation = - resolvedSchemaOrArrayAnnotation == null ? - null : - resolvedSchemaOrArrayAnnotation instanceof io.swagger.v3.oas.annotations.media.ArraySchema ? - ((io.swagger.v3.oas.annotations.media.ArraySchema) resolvedSchemaOrArrayAnnotation).schema() : - (io.swagger.v3.oas.annotations.media.Schema) resolvedSchemaOrArrayAnnotation; + final io.swagger.v3.oas.annotations.media.Schema schemaAnnotation = getSchemaAnnotation(resolvedSchemaOrArrayAnnotation); final BeanDescription beanDesc = _mapper.getSerializationConfig().introspect(type); Annotated a = beanDesc.getClassInfo(); @@ -3564,6 +3544,36 @@ private Optional resolveArraySchemaWithCycleGuard( return reResolvedProperty; } + private io.swagger.v3.oas.annotations.media.Schema getSchemaAnnotationForArray(Annotation annotation) { + if (annotation == null) { + return null; + } else { + return annotation instanceof io.swagger.v3.oas.annotations.media.ArraySchema ? + ((io.swagger.v3.oas.annotations.media.ArraySchema) annotation).arraySchema() : + (io.swagger.v3.oas.annotations.media.Schema) annotation; + } + } + + private io.swagger.v3.oas.annotations.media.Schema getSchemaAnnotation(Annotation annotation) { + if (annotation == null) { + return null; + } else { + return annotation instanceof io.swagger.v3.oas.annotations.media.ArraySchema ? + ((io.swagger.v3.oas.annotations.media.ArraySchema) annotation).schema() : + (io.swagger.v3.oas.annotations.media.Schema) annotation; + } + } + + private io.swagger.v3.oas.annotations.media.ArraySchema getArraySchemaAnnotation(Annotation annotation) { + if (annotation == null) { + return null; + } else { + return annotation instanceof io.swagger.v3.oas.annotations.media.ArraySchema ? + (io.swagger.v3.oas.annotations.media.ArraySchema) annotation : + null; + } + } + /** * Checks if the given JavaType represents a java.util.stream.Stream */ From 3043ce4d5d2db489f59726ec151f3c98df41a463 Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:42:45 +0100 Subject: [PATCH 2/2] fix: nullable is only set on an object if explicitly configured in a schema annotation --- .../v3/core/jackson/ModelResolver.java | 44 +++- .../swagger/v3/core/issues/Issue5115Test.java | 238 ++++++++++++++++++ .../specFiles/NullableObjectFieldsOAS30.json | 59 +++++ .../specFiles/NullableObjectFieldsOAS31.json | 59 +++++ ...ableObjectFieldsSchemaAnnotationOAS30.json | 49 ++++ ...ableObjectFieldsSchemaAnnotationOAS31.json | 61 +++++ 6 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 modules/swagger-core/src/test/java/io/swagger/v3/core/issues/Issue5115Test.java create mode 100644 modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsOAS30.json create mode 100644 modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsOAS31.json create mode 100644 modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsSchemaAnnotationOAS30.json create mode 100644 modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsSchemaAnnotationOAS31.json diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java index 5a84e7fcaa..666f988252 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java @@ -124,6 +124,7 @@ public class ModelResolver extends AbstractModelConverter implements ModelConver public static boolean composedModelPropertiesAsSibling = System.getProperty(SET_PROPERTY_OF_COMPOSED_MODEL_AS_SIBLING) != null; private static final int SCHEMA_COMPONENT_PREFIX = "#/components/schemas/".length(); + private static final String OBJECT_TYPE = "object"; private static final Predicate ANNOTATIONS_THAT_SHOULD_BE_STRIPPED_FOR_CONTAINER_ITEMS = annotation -> annotation.annotationType().getName().startsWith("io.swagger") || @@ -1108,11 +1109,7 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context } // check if it has "object" related keywords if (isInferredObjectSchema(model) && model.get$ref() == null) { - if (openapi31 && model.getTypes() == null) { - model.addType("object"); - } else if (!openapi31 && model.getType() == null) { - model.type("object"); - } + setSchemaTypeForObjectSchema(model, resolvedSchemaAnnotation); } Schema.SchemaResolution resolvedSchemaResolution = AnnotationsUtils.resolveSchemaResolution(this.schemaResolution, resolvedSchemaAnnotation); @@ -1150,6 +1147,24 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context return model; } + private void setSchemaTypeForObjectSchema(Schema model, io.swagger.v3.oas.annotations.media.Schema schemaAnnotation) { + if (openapi31) { + if (model.getTypes() == null) { + model.addType(OBJECT_TYPE); + } + if (!isNullableSchema(model, schemaAnnotation)) { + model.setTypes(new LinkedHashSet<>(Collections.singletonList(OBJECT_TYPE))); + } + } else { + if (model.getType() == null) { + model.type(OBJECT_TYPE); + } + if (!isNullableSchema(model, schemaAnnotation)) { + model.setNullable(null); + } + } + } + private Annotation[] addGenericTypeArgumentAnnotationsForOptionalField(BeanPropertyDefinition propDef, Annotation[] annotations) { boolean isNotOptionalType = Optional.ofNullable(propDef) @@ -3469,6 +3484,25 @@ public void setConfiguration(Configuration configuration) { } } + /** + * Currently {@code null} is not a valid type for any {@code object} other than {@code AdditionalProperties}. + * This since the resolver currently does not produce proper nullable ref:s + * @param schema The schema that should be classified + * @param schemaAnnotation The schema annotation + * @return Whether the schema is considered valid for having the {@code null} type + */ + private boolean isNullableSchema(Schema schema, io.swagger.v3.oas.annotations.media.Schema schemaAnnotation) { + if (openapi31) { + return isObjectSchema(schema) && schema.getAdditionalProperties() != null; + } else { + // If the schema annotation has explicitly set nullable to true, then keep that setting + if (schemaAnnotation != null && schemaAnnotation.nullable()) { + return true; + } + return isObjectSchema(schema) && schema.getAdditionalProperties() != null; + } + } + protected boolean isObjectSchema(Schema schema) { return SchemaTypeUtils.isObjectSchema(schema); } diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/issues/Issue5115Test.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/issues/Issue5115Test.java new file mode 100644 index 0000000000..55ccd4b657 --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/issues/Issue5115Test.java @@ -0,0 +1,238 @@ +package io.swagger.v3.core.issues; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.ResourceUtils; +import io.swagger.v3.oas.annotations.media.Schema; +import org.testng.annotations.Test; + +import javax.annotation.Nullable; +import java.io.IOException; + +import static org.testng.Assert.assertEquals; + +/** + * Reproduces GitHub Issue #5115 + * Issue using Nullable annotation on Model-fields + * + *

Tests that @Nullable annotation does not affect an object in an invalid manner. Any nullability that is configured + * with a schema annotation (nullable = true for OAS30 and null in types for OAS31) is retained. + * + *

Note: This test uses javax.annotation.Nullable which is automatically transformed to + * jakarta.annotation.Nullable in the swagger-core-jakarta module via the Eclipse Transformer. + */ +public class Issue5115Test { + + @Test + public void testObjectDoesNotGetInvalidNullableSchemaOAS31() throws IOException { + ResolvedSchema schema = ModelConverters.getInstance(true) + .readAllAsResolvedSchema(ModelWithObject.class); + + String expectedJson = ResourceUtils.loadClassResource(getClass(), "specFiles/NullableObjectFieldsOAS31.json"); + String actualJson = Json31.pretty(schema); + ObjectMapper mapper = new ObjectMapper(); + JsonNode expectedNode = mapper.readTree(expectedJson); + JsonNode actualNode = mapper.readTree(actualJson); + assertEquals(actualNode, expectedNode); + } + + @Test + public void testObjectDoesNotGetInvalidNullableSchemaOAS30() throws IOException { + ResolvedSchema schema = ModelConverters.getInstance() + .readAllAsResolvedSchema(ModelWithObject.class); + + String expectedJson = ResourceUtils.loadClassResource(getClass(), "specFiles/NullableObjectFieldsOAS30.json"); + String actualJson = Json.pretty(schema); + ObjectMapper mapper = new ObjectMapper(); + JsonNode expectedNode = mapper.readTree(expectedJson); + JsonNode actualNode = mapper.readTree(actualJson); + assertEquals(actualNode, expectedNode); + } + + @Test + public void testObjectKeepsInvalidNullableSchemaIfSetInSchemaAnnotationOAS31() throws IOException { + ResolvedSchema schema = ModelConverters.getInstance(true) + .readAllAsResolvedSchema(ModelWithObjectThatHasNullableInSchemaAnnotationOAS31.class); + + String expectedJson = ResourceUtils.loadClassResource(getClass(), "specFiles/NullableObjectFieldsSchemaAnnotationOAS31.json"); + String actualJson = Json31.pretty(schema); + ObjectMapper mapper = new ObjectMapper(); + JsonNode expectedNode = mapper.readTree(expectedJson); + JsonNode actualNode = mapper.readTree(actualJson); + assertEquals(actualNode, expectedNode); + } + + @Test + public void testObjectKeepsInvalidNullableSchemaIfSetInSchemaAnnotationOAS30() throws IOException { + ResolvedSchema schema = ModelConverters.getInstance() + .readAllAsResolvedSchema(ModelWithObjectThatHasNullableInSchemaAnnotationOAS30.class); + + String expectedJson = ResourceUtils.loadClassResource(getClass(), "specFiles/NullableObjectFieldsSchemaAnnotationOAS30.json"); + String actualJson = Json.pretty(schema); + ObjectMapper mapper = new ObjectMapper(); + JsonNode expectedNode = mapper.readTree(expectedJson); + JsonNode actualNode = mapper.readTree(actualJson); + assertEquals(actualNode, expectedNode); + } + + public static class ModelWithObject { + + @Nullable + private Model nullableModel; + + private Model model; + + @Nullable + public Model getNullableModel() { + return nullableModel; + } + + public void setNullableModel(@Nullable Model model) { + this.nullableModel = model; + } + + public Model getModel() { + return model; + } + + public void setModel(Model model) { + this.model = model; + } + } + + public static class ModelWithObjectThatHasNullableInSchemaAnnotationOAS30 { + + // This nullable is lost since the fields are read top to bottom and model does not define nullable + @Schema(nullable = true) + private NestedModel nullableModel; + + private NestedModel model; + + @Schema(nullable = true) + private NestedModel2 nullableModel2; + + public NestedModel getNullableModel() { + return nullableModel; + } + + public void setNullableModel(NestedModel model) { + this.nullableModel = model; + } + + public NestedModel getModel() { + return model; + } + + public void setModel(NestedModel model) { + this.model = model; + } + + public NestedModel2 getNullableModel2() { + return nullableModel2; + } + + public void setNullableModel2(NestedModel2 nullableModel2) { + this.nullableModel2 = nullableModel2; + } + } + + public static class ModelWithObjectThatHasNullableInSchemaAnnotationOAS31 { + + @Schema(types = {"object", "null"}) + private Model nullableModel; + + private Model model; + + public Model getNullableModel() { + return nullableModel; + } + + public void setNullableModel(Model model) { + this.nullableModel = model; + } + + public Model getModel() { + return model; + } + + public void setModel(Model model) { + this.model = model; + } + } + + public static class Model { + + private NestedModel nestedModel; + + @Nullable + private NestedModel nullableNestedModel; + + @Nullable + private NestedModel2 nullableNestedModel2; + + private NestedModel2 nestedModel2; + + public NestedModel getNestedModel() { + return nestedModel; + } + + public void setNestedModel(NestedModel nestedModel) { + this.nestedModel = nestedModel; + } + + @Nullable + public NestedModel getNullableNestedModel() { + return nullableNestedModel; + } + + public void setNullableNestedModel(@Nullable NestedModel nullableNestedModel) { + this.nullableNestedModel = nullableNestedModel; + } + + @Nullable + public NestedModel2 getNullableNestedModel2() { + return nullableNestedModel2; + } + + public void setNullableNestedModel2(@Nullable NestedModel2 nullableNestedModel2) { + this.nullableNestedModel2 = nullableNestedModel2; + } + + public NestedModel2 getNestedModel2() { + return nestedModel2; + } + + public void setNestedModel2(NestedModel2 nestedModel2) { + this.nestedModel2 = nestedModel2; + } + } + + public static class NestedModel { + private String field; + + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } + } + + public static class NestedModel2 { + private String field2; + + public String getField2() { + return field2; + } + + public void setField2(String field2) { + this.field2 = field2; + } + } + +} diff --git a/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsOAS30.json b/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsOAS30.json new file mode 100644 index 0000000000..42d0efea0b --- /dev/null +++ b/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsOAS30.json @@ -0,0 +1,59 @@ +{ + "schema" : { + "type" : "object", + "properties" : { + "nullableModel" : { + "$ref" : "#/components/schemas/Model" + }, + "model" : { + "$ref" : "#/components/schemas/Model" + } + } + }, + "referencedSchemas" : { + "Model" : { + "type" : "object", + "properties" : { + "nestedModel" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "nullableNestedModel" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "nullableNestedModel2" : { + "$ref" : "#/components/schemas/NestedModel2" + }, + "nestedModel2" : { + "$ref" : "#/components/schemas/NestedModel2" + } + } + }, + "ModelWithObject" : { + "type" : "object", + "properties" : { + "nullableModel" : { + "$ref" : "#/components/schemas/Model" + }, + "model" : { + "$ref" : "#/components/schemas/Model" + } + } + }, + "NestedModel" : { + "type" : "object", + "properties" : { + "field" : { + "type" : "string" + } + } + }, + "NestedModel2" : { + "type" : "object", + "properties" : { + "field2" : { + "type" : "string" + } + } + } + } +} \ No newline at end of file diff --git a/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsOAS31.json b/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsOAS31.json new file mode 100644 index 0000000000..42d0efea0b --- /dev/null +++ b/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsOAS31.json @@ -0,0 +1,59 @@ +{ + "schema" : { + "type" : "object", + "properties" : { + "nullableModel" : { + "$ref" : "#/components/schemas/Model" + }, + "model" : { + "$ref" : "#/components/schemas/Model" + } + } + }, + "referencedSchemas" : { + "Model" : { + "type" : "object", + "properties" : { + "nestedModel" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "nullableNestedModel" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "nullableNestedModel2" : { + "$ref" : "#/components/schemas/NestedModel2" + }, + "nestedModel2" : { + "$ref" : "#/components/schemas/NestedModel2" + } + } + }, + "ModelWithObject" : { + "type" : "object", + "properties" : { + "nullableModel" : { + "$ref" : "#/components/schemas/Model" + }, + "model" : { + "$ref" : "#/components/schemas/Model" + } + } + }, + "NestedModel" : { + "type" : "object", + "properties" : { + "field" : { + "type" : "string" + } + } + }, + "NestedModel2" : { + "type" : "object", + "properties" : { + "field2" : { + "type" : "string" + } + } + } + } +} \ No newline at end of file diff --git a/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsSchemaAnnotationOAS30.json b/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsSchemaAnnotationOAS30.json new file mode 100644 index 0000000000..6c76dd1038 --- /dev/null +++ b/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsSchemaAnnotationOAS30.json @@ -0,0 +1,49 @@ +{ + "schema" : { + "type" : "object", + "properties" : { + "nullableModel" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "model" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "nullableModel2" : { + "$ref" : "#/components/schemas/NestedModel2" + } + } + }, + "referencedSchemas" : { + "ModelWithObjectThatHasNullableInSchemaAnnotationOAS30" : { + "type" : "object", + "properties" : { + "nullableModel" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "model" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "nullableModel2" : { + "$ref" : "#/components/schemas/NestedModel2" + } + } + }, + "NestedModel" : { + "type" : "object", + "properties" : { + "field" : { + "type" : "string" + } + } + }, + "NestedModel2" : { + "type" : "object", + "properties" : { + "field2" : { + "type" : "string" + } + }, + "nullable" : true + } + } +} \ No newline at end of file diff --git a/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsSchemaAnnotationOAS31.json b/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsSchemaAnnotationOAS31.json new file mode 100644 index 0000000000..d726906a63 --- /dev/null +++ b/modules/swagger-core/src/test/resources/specFiles/NullableObjectFieldsSchemaAnnotationOAS31.json @@ -0,0 +1,61 @@ +{ + "schema" : { + "type" : "object", + "properties" : { + "nullableModel" : { + "type" : [ "object", "null" ], + "$ref" : "#/components/schemas/Model" + }, + "model" : { + "$ref" : "#/components/schemas/Model" + } + } + }, + "referencedSchemas" : { + "Model" : { + "type" : "object", + "properties" : { + "nestedModel" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "nullableNestedModel" : { + "$ref" : "#/components/schemas/NestedModel" + }, + "nullableNestedModel2" : { + "$ref" : "#/components/schemas/NestedModel2" + }, + "nestedModel2" : { + "$ref" : "#/components/schemas/NestedModel2" + } + } + }, + "ModelWithObjectThatHasNullableInSchemaAnnotationOAS31" : { + "type" : "object", + "properties" : { + "nullableModel" : { + "type" : [ "object", "null" ], + "$ref" : "#/components/schemas/Model" + }, + "model" : { + "$ref" : "#/components/schemas/Model" + } + } + }, + "NestedModel" : { + "type" : "object", + "properties" : { + "field" : { + "type" : "string" + } + } + }, + "NestedModel2" : { + "type" : "object", + "properties" : { + "field2" : { + "type" : "string" + } + } + } + } +} \ No newline at end of file