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..177750a2c --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt @@ -0,0 +1,141 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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.SpecVersion +import io.swagger.v3.oas.models.media.Schema +import org.springdoc.core.providers.ObjectMapperProvider +import kotlin.reflect.full.memberProperties + +/** + * Marks schema properties as nullable for Kotlin data class fields whose + * return type is marked nullable (`Type?`). + * + * 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 + * + * @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 + } + + 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) 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/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/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 new file mode 100644 index 000000000..2cc4d8b38 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json @@ -0,0 +1,82 @@ +{ + "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, + "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" + ] + } + } + } +}