From 0cb12af0540197b4ddb44e09b6fb02f5851e435b Mon Sep 17 00:00:00 2001 From: Stefan Wiese <14122485+itd-sw@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:51:41 +0200 Subject: [PATCH 1/2] test: cover nullable composed Kotlin properties --- .../api/v30/app18/NullableController.kt | 7 +++ .../api/v31/app23/NullableController.kt | 7 +++ .../test/resources/results/3.0.1/app18.json | 51 +++++++++++++++++++ .../test/resources/results/3.1.0/app23.json | 47 +++++++++++++++++ 4 files changed, 112 insertions(+) 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..70ddf82d6 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,37 @@ "$ref": "#/components/schemas/NestedObject" } ] + }, + "nullableComposed": { + "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..39e1ebf4b 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,39 @@ "type": "null" } ] + }, + "nullableComposed": { + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + } + ] } }, "required": [ "requiredField" ] + }, + "Second": { + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "second": { + "type": "string" + } + } + } + ], + "required": [ + "second" + ] } } } From c1d9e460fcd84bb2d183a1e0c4e18d369ac5e93d Mon Sep 17 00:00:00 2001 From: Stefan Wiese <14122485+itd-sw@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:00:09 +0200 Subject: [PATCH 2/2] fix: preserve nullability of composed Kotlin properties --- .../KotlinNullablePropertyCustomizer.kt | 17 ++++++++++++----- .../src/test/resources/results/3.0.1/app18.json | 1 + .../src/test/resources/results/3.1.0/app23.json | 3 +++ 3 files changed, 16 insertions(+), 5 deletions(-) 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/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 70ddf82d6..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 @@ -103,6 +103,7 @@ ] }, "nullableComposed": { + "nullable": true, "oneOf": [ { "$ref": "#/components/schemas/First" 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 39e1ebf4b..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 @@ -108,6 +108,9 @@ }, { "$ref": "#/components/schemas/Second" + }, + { + "type": "null" } ] }