Skip to content

Commit 47efac0

Browse files
committed
Merge pull request #3256 from thejeff77/feat/kotlin-nullable-schema-properties
feat: auto-set nullable: true for Kotlin nullable types in schema properties
1 parent f52e69b commit 47efac0

File tree

8 files changed

+391
-0
lines changed

8 files changed

+391
-0
lines changed

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ package org.springdoc.core.configuration
2828

2929
import org.springdoc.core.converters.KotlinInlineClassUnwrappingConverter
3030
import org.springdoc.core.customizers.KotlinDeprecatedPropertyCustomizer
31+
import org.springdoc.core.customizers.KotlinNullablePropertyCustomizer
3132
import org.springdoc.core.providers.ObjectMapperProvider
3233
import org.springdoc.core.utils.Constants
3334
import org.springdoc.core.utils.SpringDocKotlinUtils
@@ -78,6 +79,13 @@ class SpringDocKotlinConfiguration() {
7879
return KotlinDeprecatedPropertyCustomizer(objectMapperProvider)
7980
}
8081

82+
@Bean
83+
@Lazy(false)
84+
@ConditionalOnMissingBean
85+
fun kotlinNullablePropertyCustomizer(objectMapperProvider: ObjectMapperProvider): KotlinNullablePropertyCustomizer {
86+
return KotlinNullablePropertyCustomizer(objectMapperProvider)
87+
}
88+
8189
@Bean
8290
@Lazy(false)
8391
@ConditionalOnMissingBean
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2026 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
27+
package org.springdoc.core.customizers
28+
29+
import com.fasterxml.jackson.databind.JavaType
30+
import io.swagger.v3.core.converter.AnnotatedType
31+
import io.swagger.v3.core.converter.ModelConverter
32+
import io.swagger.v3.core.converter.ModelConverterContext
33+
import io.swagger.v3.oas.models.Components
34+
import io.swagger.v3.oas.models.SpecVersion
35+
import io.swagger.v3.oas.models.media.Schema
36+
import org.springdoc.core.providers.ObjectMapperProvider
37+
import kotlin.reflect.full.memberProperties
38+
39+
/**
40+
* Marks schema properties as nullable for Kotlin data class fields whose
41+
* return type is marked nullable (`Type?`).
42+
*
43+
* Handles both OAS 3.0 and OAS 3.1 nullable semantics:
44+
* - **OAS 3.0**: Sets `nullable: true` on the property. For `$ref` properties,
45+
* wraps in `allOf` since `$ref` and `nullable` are mutually exclusive.
46+
* - **OAS 3.1**: Adds `"null"` to the `type` array. For `$ref` properties,
47+
* wraps in `oneOf` with a `type: "null"` alternative.
48+
*
49+
* See: https://github.com/springdoc/springdoc-openapi/issues/906
50+
*
51+
* @author Jeffrey Blayney
52+
*/
53+
class KotlinNullablePropertyCustomizer(
54+
private val objectMapperProvider: ObjectMapperProvider
55+
) : ModelConverter {
56+
57+
override fun resolve(
58+
type: AnnotatedType,
59+
context: ModelConverterContext,
60+
chain: Iterator<ModelConverter>
61+
): Schema<*>? {
62+
if (!chain.hasNext()) return null
63+
val resolvedSchema = chain.next().resolve(type, context, chain)
64+
65+
val javaType: JavaType =
66+
objectMapperProvider.jsonMapper().constructType(type.type)
67+
if (javaType.rawClass.packageName.startsWith("java.")) {
68+
return resolvedSchema
69+
}
70+
71+
val kotlinClass = try {
72+
javaType.rawClass.kotlin
73+
} catch (_: Throwable) {
74+
return resolvedSchema
75+
}
76+
77+
val targetSchema = if (resolvedSchema != null && resolvedSchema.`$ref` != null) {
78+
context.getDefinedModels()[resolvedSchema.`$ref`.substring(Components.COMPONENTS_SCHEMAS_REF.length)]
79+
} else {
80+
resolvedSchema
81+
}
82+
83+
if (targetSchema?.properties == null) return resolvedSchema
84+
85+
val specVersion = targetSchema.specVersion ?: SpecVersion.V30
86+
87+
val replacements = mutableMapOf<String, Schema<*>>()
88+
for (prop in kotlinClass.memberProperties) {
89+
if (!prop.returnType.isMarkedNullable) continue
90+
val fieldName = prop.name
91+
val property = targetSchema.properties[fieldName] ?: continue
92+
93+
if (property.`$ref` != null) {
94+
replacements[fieldName] = wrapRefNullable(property.`$ref`, specVersion)
95+
} else {
96+
markNullable(property, specVersion)
97+
}
98+
}
99+
100+
replacements.forEach { (name, wrapper) ->
101+
targetSchema.properties[name] = wrapper
102+
}
103+
104+
return resolvedSchema
105+
}
106+
107+
/**
108+
* Marks a non-$ref property as nullable.
109+
* - OAS 3.0: `nullable: true`
110+
* - OAS 3.1: adds `"null"` to the `types` set
111+
*/
112+
private fun markNullable(property: Schema<*>, specVersion: SpecVersion) {
113+
if (specVersion == SpecVersion.V31) {
114+
val currentTypes = property.types ?: property.type?.let { setOf(it) } ?: emptySet()
115+
if ("null" !in currentTypes) {
116+
property.types = currentTypes + "null"
117+
}
118+
} else {
119+
property.nullable = true
120+
}
121+
}
122+
123+
/**
124+
* Wraps a $ref in a nullable composite schema.
125+
* - OAS 3.0: `{ nullable: true, allOf: [{ $ref: "..." }] }`
126+
* - OAS 3.1: `{ oneOf: [{ $ref: "..." }, { type: "null" }] }`
127+
*/
128+
private fun wrapRefNullable(ref: String, specVersion: SpecVersion): Schema<*> {
129+
val refSchema = Schema<Any>().apply { `$ref` = ref }
130+
return if (specVersion == SpecVersion.V31) {
131+
Schema<Any>().apply {
132+
oneOf = listOf(refSchema, Schema<Any>().apply { addType("null") })
133+
}
134+
} else {
135+
Schema<Any>().apply {
136+
nullable = true
137+
allOf = listOf(refSchema)
138+
}
139+
}
140+
}
141+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package test.org.springdoc.api.v30.app18
2+
3+
import org.springframework.web.bind.annotation.GetMapping
4+
import org.springframework.web.bind.annotation.RestController
5+
6+
data class NullableFieldsResponse(
7+
val requiredField: String,
8+
val nullableString: String? = null,
9+
val nullableInt: Int? = null,
10+
val nullableNested: NestedObject? = null,
11+
)
12+
13+
data class NestedObject(
14+
val name: String,
15+
val description: String? = null,
16+
)
17+
18+
@RestController
19+
class NullableController {
20+
@GetMapping("/nullable")
21+
fun getNullableFields(): NullableFieldsResponse =
22+
NullableFieldsResponse(requiredField = "hello")
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package test.org.springdoc.api.v30.app18
2+
3+
import org.springframework.boot.autoconfigure.SpringBootApplication
4+
import org.springframework.context.annotation.ComponentScan
5+
import test.org.springdoc.api.v30.AbstractKotlinSpringDocMVCTest
6+
7+
class SpringDocApp18Test : AbstractKotlinSpringDocMVCTest() {
8+
9+
@SpringBootApplication
10+
@ComponentScan(basePackages = ["org.springdoc", "test.org.springdoc.api.v30.app18"])
11+
class DemoApplication
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package test.org.springdoc.api.v31.app23
2+
3+
import org.springframework.web.bind.annotation.GetMapping
4+
import org.springframework.web.bind.annotation.RestController
5+
6+
data class NullableFieldsResponse(
7+
val requiredField: String,
8+
val nullableString: String? = null,
9+
val nullableInt: Int? = null,
10+
val nullableNested: NestedObject? = null,
11+
)
12+
13+
data class NestedObject(
14+
val name: String,
15+
val description: String? = null,
16+
)
17+
18+
@RestController
19+
class NullableController {
20+
@GetMapping("/nullable")
21+
fun getNullableFields(): NullableFieldsResponse =
22+
NullableFieldsResponse(requiredField = "hello")
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package test.org.springdoc.api.v31.app23
2+
3+
import org.springframework.boot.autoconfigure.SpringBootApplication
4+
import org.springframework.context.annotation.ComponentScan
5+
import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest
6+
7+
class SpringDocApp23Test : AbstractKotlinSpringDocMVCTest() {
8+
9+
@SpringBootApplication
10+
@ComponentScan(basePackages = ["org.springdoc", "test.org.springdoc.api.v31.app23"])
11+
class DemoApplication
12+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
{
2+
"openapi": "3.0.1",
3+
"info": {
4+
"title": "OpenAPI definition",
5+
"version": "v0"
6+
},
7+
"servers": [
8+
{
9+
"url": "http://localhost",
10+
"description": "Generated server url"
11+
}
12+
],
13+
"paths": {
14+
"/nullable": {
15+
"get": {
16+
"tags": [
17+
"nullable-controller"
18+
],
19+
"operationId": "getNullableFields",
20+
"responses": {
21+
"200": {
22+
"description": "OK",
23+
"content": {
24+
"*/*": {
25+
"schema": {
26+
"$ref": "#/components/schemas/NullableFieldsResponse"
27+
}
28+
}
29+
}
30+
}
31+
}
32+
}
33+
}
34+
},
35+
"components": {
36+
"schemas": {
37+
"NestedObject": {
38+
"required": [
39+
"name"
40+
],
41+
"type": "object",
42+
"properties": {
43+
"name": {
44+
"type": "string"
45+
},
46+
"description": {
47+
"type": "string",
48+
"nullable": true
49+
}
50+
}
51+
},
52+
"NullableFieldsResponse": {
53+
"required": [
54+
"requiredField"
55+
],
56+
"type": "object",
57+
"properties": {
58+
"requiredField": {
59+
"type": "string"
60+
},
61+
"nullableString": {
62+
"type": "string",
63+
"nullable": true
64+
},
65+
"nullableInt": {
66+
"type": "integer",
67+
"format": "int32",
68+
"nullable": true
69+
},
70+
"nullableNested": {
71+
"nullable": true,
72+
"allOf": [
73+
{
74+
"$ref": "#/components/schemas/NestedObject"
75+
}
76+
]
77+
}
78+
}
79+
}
80+
}
81+
}
82+
}

0 commit comments

Comments
 (0)