feat: auto-set nullable: true for Kotlin nullable types in schema properties#3256
Conversation
…perties 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: springdoc#906
|
Hi, are you sure that the client generator supports the OAS 3.0 structure "nullableNested": {
"nullable": true,
"$ref": "#/components/schemas/NestedObject"
}? Siblings are not supported in OAS 3.0, like how in 3.1, and more often than not any definitions next to "nullableNested": {
"nullable": true,
"allOf": [
{ "$ref": "#/components/schemas/NestedObject" }
]
}On the subject of OAS versions. Could you also add a test that shows the behavior for 3.1? Since nullability is expressed completely different in that specification. |
|
@bnasslahsen Hi! This PR adds automatic The implementation follows the exact same pattern as the existing Could you approve the CI workflow run and take a look when you get a chance? The test snapshot may need adjustment once CI runs — happy to iterate. |
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.
|
@Mattias-Sehlstedt Great catches — you were right on both counts. I pushed a fix addressing both: 1. "nullableNested": {
"nullable": true,
"allOf": [{ "$ref": "#/components/schemas/NestedObject" }]
}Updated the v30 test snapshot (app18) accordingly. 2. OAS 3.1 support added For simple types: The We verified all of this by running our service locally and inspecting the actual generated spec — discovered the OAS 3.1 issue the hard way when |
|
Additional finding from local testing: This PR fixes the |
…roperties feat: auto-set nullable: true for Kotlin nullable types in schema properties
|
This completely broke my frontend in typescript. Now open api generator created value?: string | null in stead of before value?:string. How do I disable this globally? |
|
Now {
"type": [
"integer",
"null"
],
"format": "int64"
}Vs before: {
"type": "integer",
"format": "int64"
}EDIT: removed the question after relearning how to read. |
|
Ok, now my typescript frontend ist also broken. @0xabadea How can I disable this behavior...? |
|
You can remove |
|
@0xabadea |
|
As far as typescript, this is only loosely related. This fixes a bug where nullable kotlin types are incorrectly mapped to non-nullable Openapi types. This will potentially adversely impact typescript generators or other generators based on the generated spec from kotlin API definitions - in that what it generates will now correctly become optional or nullable. The previous openapi spec was incorrect. Previously clients would have crashed if a null was correctly returned from the kotlin API and the clients were hard-coded to handle non-nullable types. Now clients that depend on the new artifact would need to update some minimal code to correctly handle the nullable type. Is it better to have a potential crash? Or is it better to fix a minor compilation error on the newly generated client? This depends on perspective and use case.. generally, a crash could be considered worse. The minor null handling tweaks on client libraries is acceptable in my opinion.. The spec is now correctly generated, which will impact client generation by requiring some small changes, but that is a downstream side-effect, not a breakage in the springdoc library itself. Regarding the customizer, I can't confirm if this is needed after startup or not, but I'd be open to other ways of doing this! |
|
Hi @thejeff77, I think there is some more nuance there. I would encourage you to read my rationale in #3269. The previous spec was not necessarily incorrect. If the Kotlin API documents to never return nulls, just undefined-s, and it never returns nulls, then the spec is correct and there is no crash. I do understand that your original use case was different. Regarding whether it's safe to remove the customizer from |
|
@0xabadea — I went back through this with fresh eyes and pulled the swagger-core history. You were right on the substance, and I want to lay out where I've landed. On the concern itself — it's valid and I underweighted it earlier. This isn't really a TypeScript-generator complaint. The actual problem is that my PR introduced auto-detection of nullable from Kotlin Why I'm pushing for reverting my PR rather than landing #3269 as the fix. I want to be direct about this because the reasoning matters: The deeper bug isn't "auto-detection is too aggressive globally." It's that users have no way to override the auto-detection per-property via the natural A global flag (#3269) addresses the symptom but leaves the per-property bug in place. A codebase that mixes Jackson configs — some DTOs Reverting cleanly removes the bug. The The proper fix lives upstream in swagger-core, not in springdoc. This Mode pattern is already established there, twice, deliberately:
Swagger-core has also been independently building auto-detection of nullable on the same rail: swagger-api/swagger-core#5018 (2025-11) added native What I've done about it:
Thanks for the patience while I caught up. |
|
Update: the upstream
Once that lands and ships, springdoc can re-introduce this customizer with |
|
@thejeff77 very much in agreement with everything that you've said. Thank you for the revert, that is indeed the better way going forward. There's one thing I want to touch on. You mention in a number of places that the In practice, we use Of course, the ability to override the global nullable mode on a per-property basis remains beneficial and perfectly in line with the ability to override the Spring-configured Jackson inclusion via |
|
@0xabadea — fair point, and I think you're right. I was overweighting the per-property override in my framing. If the team uses Updated plan for when the customizer comes back (after swagger-api/swagger-core#5161 lands):
That's effectively a generalization of #3269's flag: it's still a global toggle, but it composes cleanly with the upstream per-property |
springdoc/springdoc-openapi#3256 significantly changed our generated schema, making it incompatible with the typescript generators and in some places it was incorrect. We're pinning version 3.0.2 until a new version is available reverting this change, as proposed by springdoc/springdoc-openapi#3276
springdoc/springdoc-openapi#3256 significantly changed our generated schema, making it incompatible with the typescript generators and in some places it was incorrect. We're pinning version 3.0.2 until a new version is available reverting this change, as proposed by springdoc/springdoc-openapi#3276
springdoc/springdoc-openapi#3256 significantly changed our generated schema, making it incompatible with the typescript generators and in some places it was incorrect. We're pinning version 3.0.2 until a new version is available reverting this change, as proposed by springdoc/springdoc-openapi#3276
springdoc/springdoc-openapi#3256 significantly changed our generated schema, making it incompatible with the typescript generators and in some places it was incorrect. We're pinning version 3.0.2 until a new version is available reverting this change, as proposed by springdoc/springdoc-openapi#3276
springdoc/springdoc-openapi#3256 significantly changed our generated schema, making it incompatible with the typescript generators and in some places it was incorrect. We're pinning version 3.0.2 until a new version is available reverting this change, as proposed by springdoc/springdoc-openapi#3276
## Summary This PR upgrades the API to spring boot 4. An 'non squashed' version of the PR is available for reference [here](#4666) ## Upgrade build.gradle for spring boot 4 ## Update jdbctemplate usage to reflect null response ## Use updated arguments for ContentCachingFilter ## Update Redis Config Namespace ## Add missing caching request wrapper to filter ## Force nullability on implementation of ConverterFactory Annotations like @nullable on a Java generic bound do not propagate into Kotlin’s type system for type parameters. Because the Converter interface has been updated to use this construct Kotlin can no longer infer nullabliity ``` public interface Converter<S, T extends @nullable Object> { T convert(S source); } ``` ## Use latest health interface ## Update common paging code for new interfaces ## replace SpykBean with MockkSpykBean This is required for mockk upgrade, see https://github.com/Ninja-Squad/springmockk/blob/master/README.md#migrating-to-version-5x ## Block first can now return null values ## queryParam is now nullable ## connection pool imports have changed ## Fix exception handling test exceptions ## Fix use of jsonPath.value ## Junit callbacks are no longer nullable ## ApplicationContextInitializer is no longer nullable ## Repository.save can no longer return null ## Fix exception handling unit ## Fix IT data source configuration ## Update spring jackson configuration This also removes the `serialization` configuration for jackson 3 because it is no longer supported the `spring.jackson` configuration now applies to jackson3, not jackson2. `SerializationFeature.WRITE_DATES_AS_TIMESTAMPS now known as DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS has been changed to false to serialize dates as ISO-8601 strings.` for this reason, we remove the `WRITE_DATES_AS_TIMESTAMPS` config for jackson 3 Jackson 2 configuration has been added as `spring.jackson2`, matching the pre spring-boot-4 upgrade configuration. ## Explicitly add and configure webtestclient ## Ensure JPA Attributes are serializable The JPA specification requires that the entity attributes are Serializable` ## Remove unused dependencies ## Handle Jackson3 Exception Handling Before the spring boot 4 upgrade Jackson 2 was used by Spring MVC, meaning our exception handling responding to unmarshalling issues deal with Jackson 2 classes. Spring Boot 4 uses jackson 3 for Spring MVC. This commit updates exception handling to work solely using Jackson3 for exception handling. It also simplifies how we use Spring’s `ProblemDetail` type, which is now correctly placing properties at the top level of the returned JSON instead of in a ‘properties’ attribute. This is mostly likely due to the upgrade to Jackson 3. This allows us to simplify the expected problem JSON in our integration tests, making the JSON match the returned values we saw from Zalando Problem before moving to Spring Problem ## Method Argument Mismatch now produces a problem Before upgrading to spring boot 4 `MethodArgumentTypeMismatchException` would return a generic error. It now returns a JSON Problem, which is preferred. ## Use Jackson 3 for API `Any` types As of Spring Boot 4 API marshalling is handled by Jackson 3. In many places JSON ‘blobs’ are represented in our API model with an ‘Any’ type: For example, in `ApprovedPremisesApplication` we have `val document: Any? = null,` This is populated as follows: 1. Pull JSON from the database as a String 2. Use Jackson to unmarshall this is into an internal Jackson Type 3. Set this value on the `Any` property 4. Spring Web MVC marshalls the `Any` value into JSON in the response This works fine when the version of Jacksonused in step 2 and 4 are the same. But if Step 2 uses Jackson2 and Step 4 uses Jackson3 (which is the configuration we have after upgrading to Spring 4), Jackson3 doesn’t recognise the Jackson2 native value in the `Any` property and falls back to default serialization, resulting in something like the following: `{"array":false,"bigDecimal":false,"bigInteger":false,"binary":false,"boolean":false,"containerNode":true,"double":false,"empty":true,"float":false,"floatingPointNumber":false,"int":false,"integralNumber":false,"long":false,"missingNode":false,"nodeType":"OBJECT","null":false,"number":false,"object":true,"pojo":false,"short":false,"textual":false,"valueNode":false} ` For this reason we’ve upgraded all places that use Jackson to populate the `Any` value to use Jackson 3. Other uses of Jackson will upgraded in subsequent commits ## Migrate some benign jackson2 usage to jackson3 ## Pin openapi starter version working version springdoc/springdoc-openapi#3256 significantly changed our generated schema, making it incompatible with the typescript generators and in some places it was incorrect. We're pinning version 3.0.2 until a new version is available reverting this change, as proposed by springdoc/springdoc-openapi#3276
Problem
Springdoc correctly uses Kotlin reflection (
isMarkedNullable()viaSpringDocKotlinUtils.kotlinNullability()) to detect nullable types for the required list — nullable fields are excluded fromrequiredinSchemaUtils.fieldRequired(). However, it does not mark the property schema itself as nullable.This causes OpenAPI client generators (e.g., fabrikt, openapi-generator) to produce non-null types with null defaults when generating Kotlin clients, which fails compilation:
Solution
Adds
KotlinNullablePropertyCustomizer— aModelConverterthat inspects Kotlin data class properties viakotlin-reflectand marks nullable properties in the schema.Handles both OAS versions:
OAS 3.0 (
nullable: true)Simple types:
{ "type": "string", "nullable": true }$reftypes useallOfwrapper (since$refandnullableare mutually exclusive siblings in OAS 3.0):{ "nullable": true, "allOf": [{ "$ref": "#/components/schemas/NestedObject" }] }OAS 3.1 (
typearrays)Simple types:
{ "type": ["string", "null"] }$reftypes useoneOf:{ "oneOf": [{ "$ref": "#/components/schemas/NestedObject" }, { "type": "null" }] }The
ModelConverterdetects the spec version fromschema.specVersionand applies the correct strategy.Changes
KotlinNullablePropertyCustomizer.ktSpringDocKotlinConfiguration.ktKotlinNullablePropertyCustomizerbeanv30/app18/nullable: trueandallOfwrappingv31/app23/typearrays andoneOfwrappingAuto-registered in
SpringDocKotlinConfiguration.KotlinReflectDependingConfigurationwhenkotlin-reflectis on the classpath, following the same pattern as the existingKotlinDeprecatedPropertyCustomizer.Test
Tests verify that a controller returning a data class with nullable fields produces a spec where:
requiredField: String) are in therequiredlist and NOT marked nullablenullableString: String?,nullableInt: Int?) are marked nullable$reffields (nullableNested: NestedObject?) are wrapped appropriately for each OAS versionFixes #906