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 dfd9e3ddab..56add53950 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 @@ -113,6 +113,7 @@ public class ModelResolver extends AbstractModelConverter implements ModelConver Logger LOGGER = LoggerFactory.getLogger(ModelResolver.class); public static List NOT_NULL_ANNOTATIONS = Arrays.asList("NotNull", "NonNull", "NotBlank", "NotEmpty"); + public static List NULLABLE_ANNOTATIONS = Arrays.asList("Nullable"); public static final String SET_PROPERTY_OF_COMPOSED_MODEL_AS_SIBLING = "composed-model-properties-as-sibiling"; public static final String SET_PROPERTY_OF_ENUMS_AS_REF = "enums-as-ref"; @@ -2446,6 +2447,13 @@ protected Boolean resolveNullable(Annotated a, Annotation[] annotations, io.swag if (schema != null && schema.nullable()) { return schema.nullable(); } + if (annotations != null) { + for (Annotation annotation : annotations) { + if (NULLABLE_ANNOTATIONS.contains(annotation.annotationType().getSimpleName())) { + return true; + } + } + } return null; } @@ -3143,6 +3151,9 @@ protected void resolveSchemaMembers(Schema schema, Annotated a, Annotation[] ann Boolean nullable = resolveNullable(a, annotations, schemaAnnotation); if (nullable != null) { schema.nullable(nullable); + if (openapi31 && nullable) { + schema.addType("null"); + } } BigDecimal multipleOf = resolveMultipleOf(a, annotations, schemaAnnotation); if (multipleOf != null) { diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java index c268488511..6f687625f7 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java @@ -825,6 +825,9 @@ public static Optional getSchemaFromAnnotation( } if (schema.nullable()) { schemaObject.setNullable(schema.nullable()); + if (openapi31) { + schemaObject.addType("null"); + } } if (StringUtils.isNotBlank(schema.title())) { schemaObject.setTitle(schema.title()); diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/issues/Issue5001Test.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/issues/Issue5001Test.java new file mode 100644 index 0000000000..66287e51d5 --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/issues/Issue5001Test.java @@ -0,0 +1,186 @@ +package io.swagger.v3.core.issues; + +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverterContextImpl; +import io.swagger.v3.core.jackson.ModelResolver; +import io.swagger.v3.core.util.Configuration; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.oas.annotations.media.Schema; +import org.testng.annotations.Test; + +import javax.annotation.Nullable; +import java.util.Set; + +import static org.testng.Assert.*; + +/** + * Reproduces GitHub Issue #5001 + * Native support for @Nullable annotations to generate proper nullable types + * + * Tests that @Nullable annotation is recognized and generates appropriate nullable output: + * - OAS 3.0: nullable keyword + * - OAS 3.1: type array with "null" + * + * 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. + * + * @see ... + */ +public class Issue5001Test { + + /** + * Tests @Nullable annotation with OAS 3.1 (type array output) + */ + @Test + public void testNullableWithOAS31() throws Exception { + final ModelResolver modelResolver = new ModelResolver(Json31.mapper()); + Configuration configuration = new Configuration(); + configuration.setOpenAPI31(true); + modelResolver.setConfiguration(configuration); + final ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver); + + final io.swagger.v3.oas.models.media.Schema model = context + .resolve(new AnnotatedType(NullableModel.class)); + + assertNotNull(model); + assertNotNull(model.getProperties()); + + // Field with @Nullable should generate type array ["string", "null"] + io.swagger.v3.oas.models.media.Schema nullableField = + (io.swagger.v3.oas.models.media.Schema) model.getProperties().get("nullableString"); + assertNotNull(nullableField, "nullableString property should exist"); + assertNotNull(nullableField.getTypes(), "@Nullable should generate types array in OAS 3.1"); + assertTrue(nullableField.getTypes().contains("string"), "types should include 'string'"); + assertTrue(nullableField.getTypes().contains("null"), "types should include 'null'"); + assertEquals(((Set) nullableField.getTypes()).size(), 2, "Should have exactly 2 types"); + + // Non-nullable field should only have string type + io.swagger.v3.oas.models.media.Schema requiredField = + (io.swagger.v3.oas.models.media.Schema) model.getProperties().get("requiredString"); + assertNotNull(requiredField); + assertNotNull(requiredField.getTypes()); + assertTrue(requiredField.getTypes().contains("string")); + assertFalse(requiredField.getTypes().contains("null"), "Non-nullable field should not have 'null' type"); + } + + /** + * Tests @Nullable annotation with OAS 3.0 (nullable keyword output) + */ + @Test + public void testNullableWithOAS30() throws Exception { + final ModelResolver modelResolver = new ModelResolver(Json.mapper()); + Configuration configuration = new Configuration(); + configuration.setOpenAPI31(false); + modelResolver.setConfiguration(configuration); + final ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver); + + final io.swagger.v3.oas.models.media.Schema model = context + .resolve(new AnnotatedType(NullableModel.class)); + + assertNotNull(model); + assertNotNull(model.getProperties()); + + // Field with @Nullable should set nullable=true in OAS 3.0 + io.swagger.v3.oas.models.media.Schema nullableField = + (io.swagger.v3.oas.models.media.Schema) model.getProperties().get("nullableString"); + assertNotNull(nullableField, "nullableString property should exist"); + assertEquals(nullableField.getNullable(), Boolean.TRUE, "@Nullable should set nullable=true in OAS 3.0"); + assertEquals(nullableField.getType(), "string", "type should be 'string'"); + + // Non-nullable field should not have nullable property + io.swagger.v3.oas.models.media.Schema requiredField = + (io.swagger.v3.oas.models.media.Schema) model.getProperties().get("requiredString"); + assertNotNull(requiredField); + assertNotEquals(requiredField.getNullable(), Boolean.TRUE, "Non-nullable field should not be nullable"); + } + + /** + * Tests explicit @Schema annotations with OAS 3.1 + */ + @Test + public void testExplicitSchemaAnnotationsWithOAS31() throws Exception { + final ModelResolver modelResolver = new ModelResolver(Json31.mapper()); + Configuration configuration = new Configuration(); + configuration.setOpenAPI31(true); + modelResolver.setConfiguration(configuration); + final ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver); + + final io.swagger.v3.oas.models.media.Schema model = context + .resolve(new AnnotatedType(ExplicitSchemaModel.class)); + + assertNotNull(model); + assertNotNull(model.getProperties()); + + // @Schema(nullable=true) should set nullable property and generate types array + io.swagger.v3.oas.models.media.Schema explicitNullable = + (io.swagger.v3.oas.models.media.Schema) model.getProperties().get("explicitNullableString"); + assertNotNull(explicitNullable); + assertEquals(explicitNullable.getNullable(), Boolean.TRUE, "@Schema(nullable=true) should set nullable"); + assertTrue(explicitNullable.getTypes().contains("string")); + assertTrue(explicitNullable.getTypes().contains("null")); + + // @Schema(types={"string", "null"}) should work + io.swagger.v3.oas.models.media.Schema explicitTypes = + (io.swagger.v3.oas.models.media.Schema) model.getProperties().get("explicitTypesString"); + assertNotNull(explicitTypes); + assertNotNull(explicitTypes.getTypes()); + assertTrue(explicitTypes.getTypes().contains("string")); + assertTrue(explicitTypes.getTypes().contains("null")); + } + + /** + * Model using @Nullable annotation + * Note: Uses javax.annotation.Nullable which gets transformed to jakarta.annotation.Nullable + * in the swagger-core-jakarta module + */ + public static class NullableModel { + @Nullable + private String nullableString; + + private String requiredString; + + public String getNullableString() { + return nullableString; + } + + public void setNullableString(String nullableString) { + this.nullableString = nullableString; + } + + public String getRequiredString() { + return requiredString; + } + + public void setRequiredString(String requiredString) { + this.requiredString = requiredString; + } + } + + /** + * Model using explicit @Schema annotations + */ + public static class ExplicitSchemaModel { + @Schema(nullable = true) + private String explicitNullableString; + + @Schema(types = {"string", "null"}) + private String explicitTypesString; + + public String getExplicitNullableString() { + return explicitNullableString; + } + + public void setExplicitNullableString(String explicitNullableString) { + this.explicitNullableString = explicitNullableString; + } + + public String getExplicitTypesString() { + return explicitTypesString; + } + + public void setExplicitTypesString(String explicitTypesString) { + this.explicitTypesString = explicitTypesString; + } + } +} diff --git a/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/Schema.java b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/Schema.java index c1653fd8f9..9e1e0bb144 100644 --- a/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/Schema.java +++ b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/Schema.java @@ -2258,7 +2258,7 @@ public String toString() { sb.append(" dependentSchemas: ").append(toIndentedString(dependentSchemas)).append("\n"); sb.append(" $comment: ").append(toIndentedString($comment)).append("\n"); sb.append(" prefixItems: ").append(toIndentedString(prefixItems)).append("\n"); - sb.append(" booleanSchemaValue").append(toIndentedString(booleanSchemaValue)).append("\n"); + sb.append(" booleanSchemaValue: ").append(toIndentedString(booleanSchemaValue)).append("\n"); } sb.append("}"); return sb.toString(); @@ -2346,4 +2346,3 @@ public Schema booleanSchemaValue(Boolean booleanSchemaValue) { return this; } } -