From 608c36c7a1ef3d56cf1f74a7a47385eec7ada387 Mon Sep 17 00:00:00 2001 From: Jeffrey Blayney Date: Tue, 7 Apr 2026 12:09:25 -0600 Subject: [PATCH 1/2] feat: auto-set nullable: true for Kotlin nullable types in schema properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Springdoc correctly uses Kotlin reflection to detect nullable types (isMarkedNullable) for the required list — nullable fields are excluded from `required`. However, it does not set `nullable: true` on the property schema itself. This causes OpenAPI client generators (e.g., fabrikt, openapi-generator) to produce non-null types with null defaults, which fails Kotlin compilation. Adds KotlinNullablePropertyCustomizer that inspects Kotlin data class properties via kotlin-reflect and sets nullable: true on schema properties whose return type is marked nullable. Auto-registered in SpringDocKotlinConfiguration when kotlin-reflect is on the classpath. Fixes: https://github.com/springdoc/springdoc-openapi/issues/906 --- .../SpringDocKotlinConfiguration.kt | 8 ++ .../KotlinNullablePropertyCustomizer.kt | 92 +++++++++++++++++++ .../api/v30/app18/NullableController.kt | 23 +++++ .../api/v30/app18/SpringDocApp18Test.kt | 12 +++ .../test/resources/results/3.0.1/app18.json | 78 ++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/SpringDocApp18Test.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt index 090da8f66..86dce004a 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt @@ -28,6 +28,7 @@ package org.springdoc.core.configuration import org.springdoc.core.converters.KotlinInlineClassUnwrappingConverter import org.springdoc.core.customizers.KotlinDeprecatedPropertyCustomizer +import org.springdoc.core.customizers.KotlinNullablePropertyCustomizer import org.springdoc.core.providers.ObjectMapperProvider import org.springdoc.core.utils.Constants import org.springdoc.core.utils.SpringDocKotlinUtils @@ -77,6 +78,13 @@ class SpringDocKotlinConfiguration() { return KotlinDeprecatedPropertyCustomizer(objectMapperProvider) } + @Bean + @Lazy(false) + @ConditionalOnMissingBean + fun kotlinNullablePropertyCustomizer(objectMapperProvider: ObjectMapperProvider): KotlinNullablePropertyCustomizer { + return KotlinNullablePropertyCustomizer(objectMapperProvider) + } + @Bean @Lazy(false) @ConditionalOnMissingBean 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 new file mode 100644 index 000000000..fc8b03fb3 --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt @@ -0,0 +1,92 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package org.springdoc.core.customizers + +import com.fasterxml.jackson.databind.JavaType +import io.swagger.v3.core.converter.AnnotatedType +import io.swagger.v3.core.converter.ModelConverter +import io.swagger.v3.core.converter.ModelConverterContext +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.media.Schema +import org.springdoc.core.providers.ObjectMapperProvider +import kotlin.reflect.full.memberProperties + +/** + * Sets `nullable: true` on schema properties for Kotlin data class fields + * whose return type is marked nullable (`Type?`). + * + * Springdoc already uses Kotlin nullability to determine the `required` list + * (via [org.springdoc.core.utils.SchemaUtils.fieldRequired]), but does not set + * `nullable: true` on the property schema itself. This causes OpenAPI client + * generators (e.g., fabrikt) to produce non-null types with null defaults, + * which fails compilation in Kotlin. + * + * See: https://github.com/springdoc/springdoc-openapi/issues/906 + * + * @author Jeffrey Blayney + */ +class KotlinNullablePropertyCustomizer( + private val objectMapperProvider: ObjectMapperProvider +) : ModelConverter { + + override fun resolve( + type: AnnotatedType, + context: ModelConverterContext, + chain: Iterator + ): Schema<*>? { + if (!chain.hasNext()) return null + val resolvedSchema = chain.next().resolve(type, context, chain) + + val javaType: JavaType = + objectMapperProvider.jsonMapper().constructType(type.type) + if (javaType.rawClass.packageName.startsWith("java.")) { + return resolvedSchema + } + + val kotlinClass = try { + javaType.rawClass.kotlin + } catch (_: Throwable) { + return resolvedSchema + } + + for (prop in kotlinClass.memberProperties) { + if (prop.returnType.isMarkedNullable) { + val fieldName = prop.name + if (resolvedSchema != null && resolvedSchema.`$ref` != null) { + val schema = + context.getDefinedModels()[resolvedSchema.`$ref`.substring( + Components.COMPONENTS_SCHEMAS_REF.length + )] + schema?.properties?.get(fieldName)?.nullable = true + } else { + resolvedSchema?.properties?.get(fieldName)?.nullable = true + } + } + } + return resolvedSchema + } +} 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 new file mode 100644 index 000000000..ddf655181 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt @@ -0,0 +1,23 @@ +package test.org.springdoc.api.v30.app18 + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +data class NullableFieldsResponse( + val requiredField: String, + val nullableString: String? = null, + val nullableInt: Int? = null, + val nullableNested: NestedObject? = null, +) + +data class NestedObject( + val name: String, + val description: String? = null, +) + +@RestController +class NullableController { + @GetMapping("/nullable") + fun getNullableFields(): NullableFieldsResponse = + NullableFieldsResponse(requiredField = "hello") +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/SpringDocApp18Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/SpringDocApp18Test.kt new file mode 100644 index 000000000..04785bedc --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/SpringDocApp18Test.kt @@ -0,0 +1,12 @@ +package test.org.springdoc.api.v30.app18 + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.ComponentScan +import test.org.springdoc.api.v30.AbstractKotlinSpringDocMVCTest + +class SpringDocApp18Test : AbstractKotlinSpringDocMVCTest() { + + @SpringBootApplication + @ComponentScan(basePackages = ["org.springdoc", "test.org.springdoc.api.v30.app18"]) + class DemoApplication +} 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 new file mode 100644 index 000000000..f98e5cbea --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json @@ -0,0 +1,78 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/nullable": { + "get": { + "tags": [ + "nullable-controller" + ], + "operationId": "getNullableFields", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/NullableFieldsResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "NestedObject": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + } + }, + "NullableFieldsResponse": { + "required": [ + "requiredField" + ], + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "nullableString": { + "type": "string", + "nullable": true + }, + "nullableInt": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "nullableNested": { + "nullable": true, + "$ref": "#/components/schemas/NestedObject" + } + } + } + } + } +} From c64f5a52532ad27cf564d5310f2666580d1a2f71 Mon Sep 17 00:00:00 2001 From: Jeffrey Blayney Date: Wed, 8 Apr 2026 09:47:22 -0600 Subject: [PATCH 2/2] fix: handle $ref nullable wrapping and OAS 3.1 support Addresses reviewer feedback from @Mattias-Sehlstedt: 1. $ref properties now use allOf wrapper in OAS 3.0 instead of sibling nullable + $ref (which is not supported per spec): `{ nullable: true, allOf: [{ $ref: "..." }] }` 2. OAS 3.1 nullable support added using type arrays for simple types (`type: ["string", "null"]`) and oneOf for $ref types (`oneOf: [{ $ref: "..." }, { type: "null" }]`). 3. Added v31 test (app23) with expected snapshot showing OAS 3.1 nullable semantics alongside the existing v30 test (app18). The ModelConverter detects the spec version from the resolved schema and applies the appropriate nullable strategy. --- .../KotlinNullablePropertyCustomizer.kt | 85 ++++++++++++++---- .../api/v31/app23/NullableController.kt | 23 +++++ .../api/v31/app23/SpringDocApp23Test.kt | 12 +++ .../test/resources/results/3.0.1/app18.json | 6 +- .../test/resources/results/3.1.0/app23.json | 90 +++++++++++++++++++ 5 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/SpringDocApp23Test.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json 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 fc8b03fb3..177750a2c 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 @@ -31,19 +31,20 @@ import io.swagger.v3.core.converter.AnnotatedType import io.swagger.v3.core.converter.ModelConverter import io.swagger.v3.core.converter.ModelConverterContext import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.SpecVersion import io.swagger.v3.oas.models.media.Schema import org.springdoc.core.providers.ObjectMapperProvider import kotlin.reflect.full.memberProperties /** - * Sets `nullable: true` on schema properties for Kotlin data class fields - * whose return type is marked nullable (`Type?`). + * Marks schema properties as nullable for Kotlin data class fields whose + * return type is marked nullable (`Type?`). * - * Springdoc already uses Kotlin nullability to determine the `required` list - * (via [org.springdoc.core.utils.SchemaUtils.fieldRequired]), but does not set - * `nullable: true` on the property schema itself. This causes OpenAPI client - * generators (e.g., fabrikt) to produce non-null types with null defaults, - * which fails compilation in Kotlin. + * Handles both OAS 3.0 and OAS 3.1 nullable semantics: + * - **OAS 3.0**: Sets `nullable: true` on the property. For `$ref` properties, + * wraps in `allOf` since `$ref` and `nullable` are mutually exclusive. + * - **OAS 3.1**: Adds `"null"` to the `type` array. For `$ref` properties, + * wraps in `oneOf` with a `type: "null"` alternative. * * See: https://github.com/springdoc/springdoc-openapi/issues/906 * @@ -73,20 +74,68 @@ class KotlinNullablePropertyCustomizer( return resolvedSchema } + val targetSchema = if (resolvedSchema != null && resolvedSchema.`$ref` != null) { + context.getDefinedModels()[resolvedSchema.`$ref`.substring(Components.COMPONENTS_SCHEMAS_REF.length)] + } else { + resolvedSchema + } + + if (targetSchema?.properties == null) return resolvedSchema + + val specVersion = targetSchema.specVersion ?: SpecVersion.V30 + + val replacements = mutableMapOf>() for (prop in kotlinClass.memberProperties) { - if (prop.returnType.isMarkedNullable) { - val fieldName = prop.name - if (resolvedSchema != null && resolvedSchema.`$ref` != null) { - val schema = - context.getDefinedModels()[resolvedSchema.`$ref`.substring( - Components.COMPONENTS_SCHEMAS_REF.length - )] - schema?.properties?.get(fieldName)?.nullable = true - } else { - resolvedSchema?.properties?.get(fieldName)?.nullable = true - } + if (!prop.returnType.isMarkedNullable) continue + val fieldName = prop.name + val property = targetSchema.properties[fieldName] ?: continue + + if (property.`$ref` != null) { + replacements[fieldName] = wrapRefNullable(property.`$ref`, specVersion) + } else { + markNullable(property, specVersion) } } + + replacements.forEach { (name, wrapper) -> + targetSchema.properties[name] = wrapper + } + return resolvedSchema } + + /** + * Marks a non-$ref property as nullable. + * - OAS 3.0: `nullable: true` + * - OAS 3.1: adds `"null"` to the `types` set + */ + private fun markNullable(property: Schema<*>, specVersion: SpecVersion) { + if (specVersion == SpecVersion.V31) { + val currentTypes = property.types ?: property.type?.let { setOf(it) } ?: emptySet() + if ("null" !in currentTypes) { + property.types = currentTypes + "null" + } + } else { + property.nullable = true + } + } + + /** + * Wraps a $ref in a nullable composite schema. + * - OAS 3.0: `{ nullable: true, allOf: [{ $ref: "..." }] }` + * - OAS 3.1: `{ oneOf: [{ $ref: "..." }, { type: "null" }] }` + */ + private fun wrapRefNullable(ref: String, specVersion: SpecVersion): Schema<*> { + val refSchema = Schema().apply { `$ref` = ref } + return if (specVersion == SpecVersion.V31) { + Schema().apply { + oneOf = listOf(refSchema, Schema().apply { addType("null") }) + } + } else { + Schema().apply { + nullable = true + allOf = listOf(refSchema) + } + } + } } 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 new file mode 100644 index 000000000..2ee82dec4 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt @@ -0,0 +1,23 @@ +package test.org.springdoc.api.v31.app23 + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +data class NullableFieldsResponse( + val requiredField: String, + val nullableString: String? = null, + val nullableInt: Int? = null, + val nullableNested: NestedObject? = null, +) + +data class NestedObject( + val name: String, + val description: String? = null, +) + +@RestController +class NullableController { + @GetMapping("/nullable") + fun getNullableFields(): NullableFieldsResponse = + NullableFieldsResponse(requiredField = "hello") +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/SpringDocApp23Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/SpringDocApp23Test.kt new file mode 100644 index 000000000..ba386a6dc --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/SpringDocApp23Test.kt @@ -0,0 +1,12 @@ +package test.org.springdoc.api.v31.app23 + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.ComponentScan +import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest + +class SpringDocApp23Test : AbstractKotlinSpringDocMVCTest() { + + @SpringBootApplication + @ComponentScan(basePackages = ["org.springdoc", "test.org.springdoc.api.v31.app23"]) + class DemoApplication +} 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 f98e5cbea..2cc4d8b38 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 @@ -69,7 +69,11 @@ }, "nullableNested": { "nullable": true, - "$ref": "#/components/schemas/NestedObject" + "allOf": [ + { + "$ref": "#/components/schemas/NestedObject" + } + ] } } } 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 new file mode 100644 index 000000000..7249ceae6 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json @@ -0,0 +1,90 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/nullable": { + "get": { + "tags": [ + "nullable-controller" + ], + "operationId": "getNullableFields", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/NullableFieldsResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "NestedObject": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "NullableFieldsResponse": { + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "nullableString": { + "type": [ + "string", + "null" + ] + }, + "nullableInt": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "nullableNested": { + "oneOf": [ + { + "$ref": "#/components/schemas/NestedObject" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "requiredField" + ] + } + } + } +}