Skip to content

Commit 1419ad0

Browse files
authored
[KOTLIN-SPRING] - add support for 'openApiNullable' - jackson nullable library to handle non-required AND nullable: true properties (OpenAPITools#23804)
* feat(kotlin): add support for OpenAPI Jackson Nullable library to handle optional and nullable properties * feat(kotlin): add support for OpenAPI Jackson Nullable library to handle optional and nullable properties * add more tests for prefix/suffix * improve springBootApplication.mustache structure * regenerate samples * fix test * update documentation * fix jackson-annotations import package. regenerate samples * refactor import mappings for Jackson 3.x support and improve clarity
1 parent a070a6a commit 1419ad0

270 files changed

Lines changed: 2601 additions & 72 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bin/configs/kotlin-spring-boot-sort-validation.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ additionalProperties:
1414
useSpringBoot3: "true"
1515
generateSortValidation: "true"
1616
generatePageableConstraintValidation: "true"
17+
openApiNullable: "true"
1718
useTags: "true"
1819
requestMappingMode: api_interface

docs/generators/kotlin-spring.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
4444
|library|library template (sub-template)|<dl><dt>**spring-boot**</dt><dd>Spring-boot Server application.</dd><dt>**spring-cloud**</dt><dd>Spring-Cloud-Feign client with Spring-Boot auto-configured settings.</dd><dt>**spring-declarative-http-interface**</dt><dd>Spring Declarative Interface client</dd></dl>|spring-boot|
4545
|modelMutable|Create mutable models| |false|
4646
|modelPackage|model package for generated code| |org.openapitools.model|
47+
|openApiNullable|Enable OpenAPI Jackson Nullable library (jackson-databind-nullable) for optional + nullable properties (required: false, nullable: true). When enabled, such properties use JsonNullable&lt;T&gt; = JsonNullable.undefined() so callers can distinguish between a missing key and an explicitly provided null. Requires jackson-databind-nullable &gt;= 0.2.10 when used with useJackson3.| |false|
4748
|packageName|Generated artifact package name.| |org.openapitools|
4849
|parcelizeModels|toggle &quot;@Parcelize&quot; for generated models| |null|
4950
|reactive|use coroutines for reactive behavior| |false|
@@ -64,7 +65,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
6465
|useDeductionForOneOfInterfaces|Annotate discriminator-free oneOf interfaces with Jackson's @JsonTypeInfo(use = Id.DEDUCTION) and @JsonSubTypes so the concrete subtype is resolved from the JSON field set rather than a type-tag property. Has no effect when a discriminator is present (name-based resolution is used instead). Requires subtypes to have structurally distinct sets of properties.| |false|
6566
|useFeignClientUrl|Whether to generate Feign client with url parameter.| |true|
6667
|useFlowForArrayReturnType|Whether to use Flow for array/collection return types when reactive is enabled. If false, will use List instead.| |true|
67-
|useJackson3|Use Jackson 3 dependencies (tools.jackson package). Only available with `useSpringBoot4`. Defaults to true when `useSpringBoot4` is enabled. Incompatible with `openApiNullable`.| |false|
68+
|useJackson3|Use Jackson 3 dependencies (tools.jackson package). Only available with `useSpringBoot4`. Defaults to true when `useSpringBoot4` is enabled.| |false|
6869
|useResponseEntity|Whether (when false) to return actual type (e.g. List&lt;Fruit&gt;) and handle non-happy path responses via exceptions flow or (when true) return entire ResponseEntity (e.g. ResponseEntity&lt;List&lt;Fruit&gt;&gt;). If disabled, method are annotated using a @ResponseStatus annotation, which has the status of the first response declared in the Api definition| |true|
6970
|useSealedResponseInterfaces|Generate sealed interfaces for endpoint responses that all possible response types implement. Allows controllers to return any valid response type in a type-safe manner (e.g., sealed interface CreateUserResponse implemented by User, ConflictResponse, ErrorResponse)| |false|
7071
|useSpringBoot3|Generate code and provide dependencies for use with Spring Boot &ge; 3 (use jakarta instead of javax in imports). Enabling this option will also enable `useJakartaEe`.| |false|

modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ public class CodegenConstants {
229229
public static final String NULLABLE_REFERENCE_TYPES = "nullableReferenceTypes";
230230
public static final String NULLABLE_REFERENCE_TYPES_DESC = "Use nullable annotations in the project. Only supported on C# 8 / ASP.NET Core 3.1 or newer.";
231231

232+
public static final String OPENAPI_NULLABLE = "openApiNullable";
233+
public static final String OPENAPI_NULLABLE_DESC = "Enable OpenAPI Jackson Nullable library (jackson-databind-nullable) for optional + nullable properties.";
234+
232235
public static final String TEMPLATING_ENGINE = "templatingEngine";
233236
public static final String TEMPLATING_ENGINE_DESC = "The templating engine plugin to use: \"mustache\" (default) or \"handlebars\" (beta)";
234237

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public abstract class AbstractJavaCodegen extends DefaultCodegen implements Code
102102
public static final String ADDITIONAL_ONE_OF_TYPE_ANNOTATIONS = "additionalOneOfTypeAnnotations";
103103
public static final String ADDITIONAL_ENUM_TYPE_ANNOTATIONS = "additionalEnumTypeAnnotations";
104104
public static final String DISCRIMINATOR_CASE_SENSITIVE = "discriminatorCaseSensitive";
105-
public static final String OPENAPI_NULLABLE = "openApiNullable";
105+
public static final String OPENAPI_NULLABLE = CodegenConstants.OPENAPI_NULLABLE;
106106
public static final String JACKSON = "jackson";
107107
public static final String TEST_OUTPUT = "testOutput";
108108
public static final String IMPLICIT_HEADERS = "implicitHeaders";

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ public String getDescription() {
181181
@Setter private boolean substituteGenericPagedModel = false;
182182
@Setter private boolean useSealedResponseInterfaces = false;
183183
@Setter private boolean companionObject = false;
184+
@Getter @Setter private boolean openApiNullable = false;
184185
@Getter @Setter
185186
protected boolean useDeductionForOneOfInterfaces = false;
186187

@@ -290,7 +291,7 @@ public KotlinSpringServerCodegen() {
290291
" (contexts) added to single project.", beanQualifiers);
291292
addSwitch(USE_SPRING_BOOT3, "Generate code and provide dependencies for use with Spring Boot ≥ 3 (use jakarta instead of javax in imports). Enabling this option will also enable `useJakartaEe`.", useSpringBoot3);
292293
addSwitch(USE_SPRING_BOOT4, "Generate code and provide dependencies for use with Spring Boot 4.x. Enabling this option will also enable `useJakartaEe`.", useSpringBoot4);
293-
addSwitch(USE_JACKSON_3, "Use Jackson 3 dependencies (tools.jackson package). Only available with `useSpringBoot4`. Defaults to true when `useSpringBoot4` is enabled. Incompatible with `openApiNullable`.", useJackson3);
294+
addSwitch(USE_JACKSON_3, "Use Jackson 3 dependencies (tools.jackson package). Only available with `useSpringBoot4`. Defaults to true when `useSpringBoot4` is enabled.", useJackson3);
294295
addSwitch(USE_FLOW_FOR_ARRAY_RETURN_TYPE, "Whether to use Flow for array/collection return types when reactive is enabled. If false, will use List instead.", useFlowForArrayReturnType);
295296
addSwitch(INCLUDE_HTTP_REQUEST_CONTEXT, "Whether to include HttpServletRequest (blocking) or ServerWebExchange (reactive) as additional parameter in generated methods.", includeHttpRequestContext);
296297
addSwitch(USE_RESPONSE_ENTITY,
@@ -314,6 +315,12 @@ public KotlinSpringServerCodegen() {
314315
substituteGenericPagedModel);
315316
addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject);
316317
cliOptions.add(CliOption.newBoolean(CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES, CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES_DESC, useDeductionForOneOfInterfaces));
318+
addSwitch(CodegenConstants.OPENAPI_NULLABLE,
319+
"Enable OpenAPI Jackson Nullable library (jackson-databind-nullable) for optional + nullable "
320+
+ "properties (required: false, nullable: true). When enabled, such properties use "
321+
+ "JsonNullable<T> = JsonNullable.undefined() so callers can distinguish between a missing key "
322+
+ "and an explicitly provided null. Requires jackson-databind-nullable >= 0.2.10 when used with useJackson3.",
323+
openApiNullable);
317324
supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application.");
318325
supportedLibraries.put(SPRING_CLOUD_LIBRARY,
319326
"Spring-Cloud-Feign client with Spring-Boot auto-configured settings.");
@@ -543,10 +550,14 @@ public void processOpts() {
543550
// used later in recursive import in postProcessingModels
544551
importMapping.put("com.fasterxml.jackson.annotation.JsonProperty", "com.fasterxml.jackson.annotation.JsonCreator");
545552

546-
if (isUseJackson3()) {
547-
// Override databind imports for Jackson 3
548-
importMapping.put("JsonDeserialize", "tools.jackson.databind.annotation.JsonDeserialize");
549-
}
553+
// Jackson 3.x intentionally kept jackson-annotations at 2.x (com.fasterxml.jackson.annotation).
554+
// Only jackson-databind moved to tools.jackson.databind in Jackson 3.x.
555+
importMapping.put("JsonSetter", "com.fasterxml.jackson.annotation.JsonSetter");
556+
importMapping.put("Nulls", "com.fasterxml.jackson.annotation.Nulls");
557+
// jackson-databind-nullable >= 0.2.10 supports both Jackson 2 and 3.
558+
importMapping.put("JsonNullable", "org.openapitools.jackson.nullable.JsonNullable");
559+
// JsonDeserialize lives in jackson-databind which moved packages in Jackson 3.x.
560+
importMapping.put("JsonDeserialize", (isUseJackson3() ? JACKSON3_PACKAGE : JACKSON2_PACKAGE) + ".databind.annotation.JsonDeserialize");
550561

551562
// Spring-specific import mappings for x-spring-paginated support
552563
importMapping.put("ParameterObject", "org.springdoc.api.annotations.ParameterObject");
@@ -767,10 +778,10 @@ public void processOpts() {
767778
throw new IllegalArgumentException("useJackson3 is only available with Spring Boot >= 4");
768779
}
769780

770-
if (isUseJackson3() && additionalProperties.containsKey("openApiNullable")
771-
&& Boolean.parseBoolean(additionalProperties.get("openApiNullable").toString())) {
772-
throw new IllegalArgumentException("openApiNullable cannot be set with useJackson3");
781+
if (additionalProperties.containsKey(CodegenConstants.OPENAPI_NULLABLE)) {
782+
this.setOpenApiNullable(convertPropertyToBoolean(CodegenConstants.OPENAPI_NULLABLE));
773783
}
784+
writePropertyBack(CodegenConstants.OPENAPI_NULLABLE, openApiNullable);
774785

775786
if (isUseSpringBoot3() || isUseSpringBoot4()) {
776787
if (AnnotationLibrary.SWAGGER1.equals(getAnnotationLibrary())) {
@@ -1321,6 +1332,21 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert
13211332
property.example = null;
13221333
}
13231334

1335+
// Scenario 3: optional + non-nullable → block explicit JSON nulls via @JsonSetter(nulls = Nulls.FAIL).
1336+
// Missing keys still succeed (default = null is used), but explicit {"field": null} fails deserialization.
1337+
if (!Boolean.TRUE.equals(property.required) && !Boolean.TRUE.equals(property.isNullable)) {
1338+
property.vendorExtensions.put("x-has-json-setter-nulls-fail", true);
1339+
model.imports.add("JsonSetter");
1340+
model.imports.add("Nulls");
1341+
}
1342+
1343+
// Scenario 4: optional + nullable with openApiNullable → use JsonNullable<T> = JsonNullable.undefined()
1344+
// so callers can distinguish between a missing key and an explicitly provided null.
1345+
if (openApiNullable && !Boolean.TRUE.equals(property.required) && Boolean.TRUE.equals(property.isNullable)) {
1346+
property.vendorExtensions.put("x-is-jackson-optional-nullable", true);
1347+
model.imports.add("JsonNullable");
1348+
}
1349+
13241350
//Add imports for Jackson
13251351
if (!Boolean.TRUE.equals(model.isEnum)) {
13261352
model.imports.add("JsonProperty");
@@ -1475,6 +1501,23 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) {
14751501
//Add imports for Jackson
14761502
List<Map<String, String>> imports = objs.getImports();
14771503

1504+
// Set 4-state nullable/required vendor extensions on optionalVars instances.
1505+
// optionalVars are cloned independently from vars by removeAllDuplicatedProperty(),
1506+
// so they require a separate pass here — postProcessModelProperty only covers vars.
1507+
for (ModelMap mo : objs.getModels()) {
1508+
CodegenModel cm = mo.getModel();
1509+
for (CodegenProperty var : cm.optionalVars) {
1510+
// Scenario 3: optional + non-nullable → block explicit JSON nulls via @JsonSetter(nulls = Nulls.FAIL)
1511+
if (!var.required && !var.isNullable) {
1512+
var.vendorExtensions.put("x-has-json-setter-nulls-fail", true);
1513+
}
1514+
// Scenario 4: optional + nullable with openApiNullable → use JsonNullable<T>
1515+
if (openApiNullable && !var.required && var.isNullable) {
1516+
var.vendorExtensions.put("x-is-jackson-optional-nullable", true);
1517+
}
1518+
}
1519+
}
1520+
14781521
objs.getModels().stream()
14791522
.map(ModelMap::getModel)
14801523
.filter(cm -> Boolean.TRUE.equals(cm.isEnum) && cm.allowableValues != null)

modules/openapi-generator/src/main/resources/kotlin-spring/dataClassOptVar.mustache

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
@Schema({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeInNormalString}}{{{.}}}{{/lambdaEscapeInNormalString}}{{/lambdaRemoveLineBreak}}", {{/example}}{{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}description = "{{{description}}}"){{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}
33
@ApiModelProperty({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeInNormalString}}{{{.}}}{{/lambdaEscapeInNormalString}}{{/lambdaRemoveLineBreak}}", {{/example}}{{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}value = "{{{description}}}"){{/swagger1AnnotationLibrary}}{{#deprecated}}
44
@Deprecated(message = ""){{/deprecated}}{{#vendorExtensions.x-field-extra-annotation}}
5-
{{{.}}}{{/vendorExtensions.x-field-extra-annotation}}
6-
@get:JsonProperty("{{{baseName}}}"){{#isInherited}} override{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}? = {{^defaultValue}}null{{/defaultValue}}{{#defaultValue}}{{^isNumber}}{{{defaultValue}}}{{/isNumber}}{{#isNumber}}{{{dataType}}}("{{{defaultValue}}}"){{/isNumber}}{{/defaultValue}}
5+
{{{.}}}{{/vendorExtensions.x-field-extra-annotation}}{{#vendorExtensions.x-has-json-setter-nulls-fail}}
6+
@field:JsonSetter(nulls = Nulls.FAIL){{/vendorExtensions.x-has-json-setter-nulls-fail}}
7+
@get:JsonProperty("{{{baseName}}}"){{#isInherited}} override{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#vendorExtensions.x-is-jackson-optional-nullable}}JsonNullable<{{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}>{{/vendorExtensions.x-is-jackson-optional-nullable}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}?{{/vendorExtensions.x-is-jackson-optional-nullable}} = {{#vendorExtensions.x-is-jackson-optional-nullable}}JsonNullable.undefined(){{/vendorExtensions.x-is-jackson-optional-nullable}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{^defaultValue}}null{{/defaultValue}}{{#defaultValue}}{{^isNumber}}{{{defaultValue}}}{{/isNumber}}{{#isNumber}}{{{dataType}}}("{{{defaultValue}}}"){{/isNumber}}{{/defaultValue}}{{/vendorExtensions.x-is-jackson-optional-nullable}}

modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-boot/buildGradle-sb3-Kts.mustache

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ dependencies {
5252
{{#useBeanValidation}}
5353
implementation("org.springframework.boot:spring-boot-starter-validation")
5454
implementation("jakarta.validation:jakarta.validation-api"){{/useBeanValidation}}
55+
{{#openApiNullable}}
56+
implementation("org.openapitools:jackson-databind-nullable:0.2.10"){{/openApiNullable}}
5557
implementation("jakarta.annotation:jakarta.annotation-api:2.1.0")
5658

5759
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")

modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-boot/buildGradle-sb4-Kts.mustache

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ dependencies {
6161
{{#useBeanValidation}}
6262
implementation("org.springframework.boot:spring-boot-starter-validation")
6363
implementation("jakarta.validation:jakarta.validation-api"){{/useBeanValidation}}
64+
{{#openApiNullable}}
65+
implementation("org.openapitools:jackson-databind-nullable:0.2.10"){{/openApiNullable}}
6466
implementation("jakarta.annotation:jakarta.annotation-api:3.0.0")
6567

6668
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")

modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-boot/buildGradleKts.mustache

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ dependencies {
5959
{{#useBeanValidation}}
6060
implementation("org.springframework.boot:spring-boot-starter-validation")
6161
implementation("javax.validation:validation-api"){{/useBeanValidation}}
62+
{{#openApiNullable}}
63+
implementation("org.openapitools:jackson-databind-nullable:0.2.8"){{/openApiNullable}}
6264
implementation("javax.annotation:javax.annotation-api:1.3.2")
6365
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
6466
testImplementation("org.springframework.boot:spring-boot-starter-test") {

modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-boot/pom-sb3.mustache

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
<swagger-annotations.version>2.2.15</swagger-annotations.version>{{/swagger2AnnotationLibrary}}{{/springDocDocumentationProvider}}
1414
<findbugs-jsr305.version>3.0.2</findbugs-jsr305.version>
1515
<jakarta-annotation.version>2.1.0</jakarta-annotation.version>
16-
<kotlin-test-junit5.version>1.7.10</kotlin-test-junit5.version>
16+
<kotlin-test-junit5.version>1.9.25</kotlin-test-junit5.version>
1717

18-
<kotlin.version>1.7.10</kotlin.version>
18+
<kotlin.version>1.9.25</kotlin.version>
1919
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
2020
</properties>
2121
<parent>
@@ -197,12 +197,23 @@
197197
<groupId>org.springframework.boot</groupId>
198198
<artifactId>spring-boot-starter-validation</artifactId>
199199
</dependency>{{/useBeanValidation}}
200+
{{#openApiNullable}}
201+
<dependency>
202+
<groupId>org.openapitools</groupId>
203+
<artifactId>jackson-databind-nullable</artifactId>
204+
<version>0.2.10</version>
205+
</dependency>{{/openApiNullable}}
200206
<dependency>
201207
<groupId>jakarta.annotation</groupId>
202208
<artifactId>jakarta.annotation-api</artifactId>
203209
<version>${jakarta-annotation.version}</version>
204210
<scope>provided</scope>
205211
</dependency>
212+
<dependency>
213+
<groupId>org.springframework.boot</groupId>
214+
<artifactId>spring-boot-starter-test</artifactId>
215+
<scope>test</scope>
216+
</dependency>
206217
<dependency>
207218
<groupId>org.jetbrains.kotlin</groupId>
208219
<artifactId>kotlin-test-junit5</artifactId>

0 commit comments

Comments
 (0)