Skip to content

Commit c64f5a5

Browse files
committed
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.
1 parent 608c36c commit c64f5a5

File tree

5 files changed

+197
-19
lines changed

5 files changed

+197
-19
lines changed

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,20 @@ import io.swagger.v3.core.converter.AnnotatedType
3131
import io.swagger.v3.core.converter.ModelConverter
3232
import io.swagger.v3.core.converter.ModelConverterContext
3333
import io.swagger.v3.oas.models.Components
34+
import io.swagger.v3.oas.models.SpecVersion
3435
import io.swagger.v3.oas.models.media.Schema
3536
import org.springdoc.core.providers.ObjectMapperProvider
3637
import kotlin.reflect.full.memberProperties
3738

3839
/**
39-
* Sets `nullable: true` on schema properties for Kotlin data class fields
40-
* whose return type is marked nullable (`Type?`).
40+
* Marks schema properties as nullable for Kotlin data class fields whose
41+
* return type is marked nullable (`Type?`).
4142
*
42-
* Springdoc already uses Kotlin nullability to determine the `required` list
43-
* (via [org.springdoc.core.utils.SchemaUtils.fieldRequired]), but does not set
44-
* `nullable: true` on the property schema itself. This causes OpenAPI client
45-
* generators (e.g., fabrikt) to produce non-null types with null defaults,
46-
* which fails compilation in Kotlin.
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.
4748
*
4849
* See: https://github.com/springdoc/springdoc-openapi/issues/906
4950
*
@@ -73,20 +74,68 @@ class KotlinNullablePropertyCustomizer(
7374
return resolvedSchema
7475
}
7576

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<*>>()
7688
for (prop in kotlinClass.memberProperties) {
77-
if (prop.returnType.isMarkedNullable) {
78-
val fieldName = prop.name
79-
if (resolvedSchema != null && resolvedSchema.`$ref` != null) {
80-
val schema =
81-
context.getDefinedModels()[resolvedSchema.`$ref`.substring(
82-
Components.COMPONENTS_SCHEMAS_REF.length
83-
)]
84-
schema?.properties?.get(fieldName)?.nullable = true
85-
} else {
86-
resolvedSchema?.properties?.get(fieldName)?.nullable = true
87-
}
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)
8897
}
8998
}
99+
100+
replacements.forEach { (name, wrapper) ->
101+
targetSchema.properties[name] = wrapper
102+
}
103+
90104
return resolvedSchema
91105
}
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+
}
92141
}
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+
}

springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@
6969
},
7070
"nullableNested": {
7171
"nullable": true,
72-
"$ref": "#/components/schemas/NestedObject"
72+
"allOf": [
73+
{
74+
"$ref": "#/components/schemas/NestedObject"
75+
}
76+
]
7377
}
7478
}
7579
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
{
2+
"openapi": "3.1.0",
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+
"type": "object",
39+
"properties": {
40+
"name": {
41+
"type": "string"
42+
},
43+
"description": {
44+
"type": [
45+
"string",
46+
"null"
47+
]
48+
}
49+
},
50+
"required": [
51+
"name"
52+
]
53+
},
54+
"NullableFieldsResponse": {
55+
"type": "object",
56+
"properties": {
57+
"requiredField": {
58+
"type": "string"
59+
},
60+
"nullableString": {
61+
"type": [
62+
"string",
63+
"null"
64+
]
65+
},
66+
"nullableInt": {
67+
"type": [
68+
"integer",
69+
"null"
70+
],
71+
"format": "int32"
72+
},
73+
"nullableNested": {
74+
"oneOf": [
75+
{
76+
"$ref": "#/components/schemas/NestedObject"
77+
},
78+
{
79+
"type": "null"
80+
}
81+
]
82+
}
83+
},
84+
"required": [
85+
"requiredField"
86+
]
87+
}
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)