diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt index 7c1b582b8..101410e0d 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt @@ -127,7 +127,7 @@ class KotlinNullablePropertyCustomizer( /** * Marks a non-$ref property as nullable. * - OAS 3.0: `nullable: true` - * - OAS 3.1: adds `"null"` to the `types` set + * - OAS 3.1: adds `"null"` to the `types` set, or to `oneOf` for a composed schema * * A schema representing `Any?` carries no `type`/`types` constraint, i.e. it is an * "any" schema which already permits any value including `null`. Such a schema is left @@ -138,9 +138,15 @@ class KotlinNullablePropertyCustomizer( if (isAnySchema(property)) return if (specVersion == SpecVersion.V31) { - val currentTypes = property.types ?: property.type?.let { setOf(it) } ?: emptySet() - if ("null" !in currentTypes) { - property.types = currentTypes + "null" + if (!property.oneOf.isNullOrEmpty()) { + if (property.oneOf.none { it.type == "null" || "null" in (it.types ?: emptySet()) }) { + property.oneOf = property.oneOf + Schema().apply { addType("null") } + } + } else { + val currentTypes = property.types ?: property.type?.let { setOf(it) } ?: emptySet() + if ("null" !in currentTypes) { + property.types = currentTypes + "null" + } } } else { property.nullable = true @@ -155,7 +161,8 @@ class KotlinNullablePropertyCustomizer( private fun isAnySchema(property: Schema<*>): Boolean = property.`$ref` == null && property.type == null && - property.types.isNullOrEmpty() + property.types.isNullOrEmpty() && + property.oneOf.isNullOrEmpty() /** * Wraps a $ref property in a nullable composite schema. A fresh wrapper schema is returned diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt index 88d11b2f0..836bf5e81 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt @@ -11,6 +11,7 @@ data class NullableFieldsResponse( val nullableAny: Any? = null, @field:Schema(description = "The nested object") val nullableNested: NestedObject? = null, + val nullableComposed: ComposedObject? = null, ) data class NestedObject( @@ -18,6 +19,12 @@ data class NestedObject( val description: String? = null, ) +sealed class ComposedObject { + data class First(val first: String) : ComposedObject() + + data class Second(val second: String) : ComposedObject() +} + @RestController class NullableController { @GetMapping("/nullable") diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt index 8caecbdc3..f3d861cd5 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt @@ -11,6 +11,7 @@ data class NullableFieldsResponse( val nullableAny: Any? = null, @field:Schema(description = "The nested object") val nullableNested: NestedObject? = null, + val nullableComposed: ComposedObject? = null, ) data class NestedObject( @@ -18,6 +19,12 @@ data class NestedObject( val description: String? = null, ) +sealed class ComposedObject { + data class First(val first: String) : ComposedObject() + + data class Second(val second: String) : ComposedObject() +} + @RestController class NullableController { @GetMapping("/nullable") diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json index cbcb8e772..0c80bbc6a 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json @@ -34,6 +34,28 @@ }, "components": { "schemas": { + "ComposedObject": { + "type": "object" + }, + "First": { + "required": [ + "first" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "first": { + "type": "string" + } + } + } + ] + }, "NestedObject": { "required": [ "name" @@ -79,8 +101,38 @@ "$ref": "#/components/schemas/NestedObject" } ] + }, + "nullableComposed": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + } + ] } } + }, + "Second": { + "required": [ + "second" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "second": { + "type": "string" + } + } + } + ] } } } diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json index f25a1d44f..f1432afd3 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json @@ -34,6 +34,25 @@ }, "components": { "schemas": { + "ComposedObject": { }, + "First": { + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "first": { + "type": "string" + } + } + } + ], + "required": [ + "first" + ] + }, "NestedObject": { "type": "object", "properties": { @@ -81,11 +100,42 @@ "type": "null" } ] + }, + "nullableComposed": { + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + }, + { + "type": "null" + } + ] } }, "required": [ "requiredField" ] + }, + "Second": { + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "second": { + "type": "string" + } + } + } + ], + "required": [ + "second" + ] } } }