Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion docs/generators/kotlin-spring.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|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|
|modelMutable|Create mutable models| |false|
|modelPackage|model package for generated code| |org.openapitools.model|
|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|
|openApiNullable|Enable OpenAPI Jackson Nullable library (jackson-databind-nullable) for strict null handling. Controls how optional + non-nullable properties (required: false, nullable: false) handle explicit JSON null: when false (default), @JsonSetter(nulls = Nulls.SKIP) is used &mdash; explicit null is silently ignored (lenient, protects any default value from being overridden); when true, @JsonSetter(nulls = Nulls.FAIL) is used &mdash; explicit null causes deserialization to fail (strict, enforces the non-nullable contract, useful for PATCH semantics). Additionally, when true, optional + nullable properties (required: false, nullable: true) use JsonNullable&lt;T&gt; = JsonNullable.undefined() to distinguish between a missing key and an explicit null. Requires jackson-databind-nullable &gt;= 0.2.10 when used with useJackson3.| |false|
|packageName|Generated artifact package name.| |org.openapitools|
|parcelizeModels|toggle &quot;@Parcelize&quot; for generated models| |null|
|reactive|use coroutines for reactive behavior| |false|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,15 @@ public KotlinSpringServerCodegen() {
cliOptions.add(CliOption.newBoolean(CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES, CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES_DESC, useDeductionForOneOfInterfaces));
addSwitch(CodegenConstants.USE_ENUM_VALUE_INTERFACE, CodegenConstants.USE_ENUM_VALUE_INTERFACE_DESC, useEnumValueInterface);
addSwitch(CodegenConstants.OPENAPI_NULLABLE,
"Enable OpenAPI Jackson Nullable library (jackson-databind-nullable) for optional + nullable "
+ "properties (required: false, nullable: true). When enabled, such properties use "
+ "JsonNullable<T> = JsonNullable.undefined() so callers can distinguish between a missing key "
+ "and an explicitly provided null. Requires jackson-databind-nullable >= 0.2.10 when used with useJackson3.",
"Enable OpenAPI Jackson Nullable library (jackson-databind-nullable) for strict null handling. "
+ "Controls how optional + non-nullable properties (required: false, nullable: false) handle explicit JSON null: "
+ "when false (default), @JsonSetter(nulls = Nulls.SKIP) is used — explicit null is silently ignored "
+ "(lenient, protects any default value from being overridden); "
+ "when true, @JsonSetter(nulls = Nulls.FAIL) is used — explicit null causes deserialization to fail "
+ "(strict, enforces the non-nullable contract, useful for PATCH semantics). "
+ "Additionally, when true, optional + nullable properties (required: false, nullable: true) use "
+ "JsonNullable<T> = JsonNullable.undefined() to distinguish between a missing key and an explicit null. "
+ "Requires jackson-databind-nullable >= 0.2.10 when used with useJackson3.",
openApiNullable);
supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application.");
supportedLibraries.put(SPRING_CLOUD_LIBRARY,
Expand Down Expand Up @@ -551,6 +556,7 @@ public void processOpts() {
// Only jackson-databind moved to tools.jackson.databind in Jackson 3.x.
importMapping.put("JsonSetter", "com.fasterxml.jackson.annotation.JsonSetter");
importMapping.put("Nulls", "com.fasterxml.jackson.annotation.Nulls");
importMapping.put("JsonInclude", "com.fasterxml.jackson.annotation.JsonInclude");
// jackson-databind-nullable >= 0.2.10 supports both Jackson 2 and 3.
importMapping.put("JsonNullable", "org.openapitools.jackson.nullable.JsonNullable");
// JsonDeserialize lives in jackson-databind which moved packages in Jackson 3.x.
Expand Down Expand Up @@ -1266,12 +1272,20 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert
property.example = null;
}

// Scenario 3: optional + non-nullable → block explicit JSON nulls via @JsonSetter(nulls = Nulls.FAIL).
// Missing keys still succeed (default = null is used), but explicit {"field": null} fails deserialization.
// Scenario 3: optional + non-nullable → always emit @JsonSetter to handle explicit JSON nulls.
// When openApiNullable=true: Nulls.FAIL → reject explicit null (strict PATCH semantics).
// When openApiNullable=false: Nulls.SKIP → silently ignore explicit null (lenient, protects defaults).
// Always emit @JsonInclude(NON_NULL) so null fields are omitted from serialized output regardless
// of who is deserializing on the other end — closer to spec, avoids round-trip failures.
if (!property.required && !property.isNullable) {
property.vendorExtensions.put("x-has-json-setter-nulls-fail", true);
if (openApiNullable) {
property.vendorExtensions.put("x-has-json-setter-nulls-fail", true);
} else {
property.vendorExtensions.put("x-has-json-setter-nulls-skip", true);
}
model.imports.add("JsonSetter");
model.imports.add("Nulls");
model.imports.add("JsonInclude");
}

// Scenario 4: optional + nullable with openApiNullable → use JsonNullable<T> = JsonNullable.undefined()
Expand Down Expand Up @@ -1444,9 +1458,14 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) {
for (ModelMap mo : objs.getModels()) {
CodegenModel cm = mo.getModel();
for (CodegenProperty var : cm.optionalVars) {
// Scenario 3: optional + non-nullable → block explicit JSON nulls via @JsonSetter(nulls = Nulls.FAIL)
// Scenario 3: optional + non-nullable → always emit @JsonSetter and @JsonInclude(NON_NULL).
// openApiNullable=true: Nulls.FAIL (strict). openApiNullable=false: Nulls.SKIP (lenient).
if (!var.required && !var.isNullable) {
var.vendorExtensions.put("x-has-json-setter-nulls-fail", true);
if (openApiNullable) {
var.vendorExtensions.put("x-has-json-setter-nulls-fail", true);
} else {
var.vendorExtensions.put("x-has-json-setter-nulls-skip", true);
}
}
// Scenario 4: optional + nullable with openApiNullable → use JsonNullable<T>
if (openApiNullable && !var.required && var.isNullable) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,10 @@ public void processOpts() {

// override parent one
importMapping.put("JsonDeserialize", (useJackson3 ? JACKSON3_PACKAGE : JACKSON2_PACKAGE) + ".databind.annotation.JsonDeserialize");
// JsonSetter and Nulls always come from com.fasterxml.jackson.annotation regardless of Jackson 2 or 3
// (Jackson 3.x intentionally keeps jackson-annotations at 2.x, same package)
importMapping.put("JsonSetter", "com.fasterxml.jackson.annotation.JsonSetter");
importMapping.put("Nulls", "com.fasterxml.jackson.annotation.Nulls");

typeMapping.put("file", "org.springframework.core.io.Resource");
importMapping.put("Nullable", useJspecify? "org.jspecify.annotations.Nullable": "org.springframework.lang.Nullable");
Expand Down Expand Up @@ -1200,6 +1204,18 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert
if (model.getVendorExtensions().containsKey("x-jackson-optional-nullable-helpers")) {
model.imports.add("Arrays");
}

// Optional + non-nullable: always emit @JsonInclude(NON_NULL) so null fields are omitted from
// serialized output regardless of who deserializes on the other end — closer to spec.
// When openApiNullable=false, also add @JsonSetter(nulls = Nulls.SKIP) on the setter.
if (!property.required && !property.isNullable) {
model.imports.add("JsonInclude");
if (!openApiNullable) {
property.vendorExtensions.put("x-has-json-setter-nulls-skip", true);
model.imports.add("JsonSetter");
model.imports.add("Nulls");
}
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
{{#vendorExtensions.x-field-extra-annotation}}
{{{.}}}
{{/vendorExtensions.x-field-extra-annotation}}
{{#jackson}}
{{^required}}
{{^isNullable}}
@JsonInclude(JsonInclude.Include.NON_NULL)
{{/isNullable}}
{{/required}}
{{/jackson}}
{{#deprecated}}
@Deprecated
{{/deprecated}}
Expand Down Expand Up @@ -242,6 +249,11 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
{{#vendorExtensions.x-setter-extra-annotation}}
{{{vendorExtensions.x-setter-extra-annotation}}}
{{/vendorExtensions.x-setter-extra-annotation}}
{{#jackson}}
{{#vendorExtensions.x-has-json-setter-nulls-skip}}
@JsonSetter(nulls = Nulls.SKIP)
{{/vendorExtensions.x-has-json-setter-nulls-skip}}
{{/jackson}}
{{#deprecated}}
@Deprecated
{{/deprecated}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
@Schema({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeInNormalString}}{{{.}}}{{/lambdaEscapeInNormalString}}{{/lambdaRemoveLineBreak}}", {{/example}}{{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}description = "{{{description}}}"){{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}
@ApiModelProperty({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeInNormalString}}{{{.}}}{{/lambdaEscapeInNormalString}}{{/lambdaRemoveLineBreak}}", {{/example}}{{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}value = "{{{description}}}"){{/swagger1AnnotationLibrary}}{{#deprecated}}
@Deprecated(message = ""){{/deprecated}}{{#vendorExtensions.x-field-extra-annotation}}
{{{.}}}{{/vendorExtensions.x-field-extra-annotation}}{{#vendorExtensions.x-has-json-setter-nulls-fail}}
{{{.}}}{{/vendorExtensions.x-field-extra-annotation}}{{^isNullable}}
@field:JsonInclude(JsonInclude.Include.NON_NULL){{/isNullable}}{{#vendorExtensions.x-has-json-setter-nulls-skip}}
@field:JsonSetter(nulls = Nulls.SKIP){{/vendorExtensions.x-has-json-setter-nulls-skip}}{{#vendorExtensions.x-has-json-setter-nulls-fail}}
@field:JsonSetter(nulls = Nulls.FAIL){{/vendorExtensions.x-has-json-setter-nulls-fail}}
@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}}
Original file line number Diff line number Diff line change
Expand Up @@ -6404,26 +6404,89 @@ public void requiredNullable_scenario2_requiredNullable() throws IOException {
}

/**
* Scenario 3: required=false, nullable=false
* Expected: nullable type with null default, AND @field:JsonSetter(nulls=Nulls.FAIL) to block explicit nulls.
* Scenario 3: required=false, nullable=false, no default, openApiNullable=false (default).
* Without openApiNullable, use lenient @JsonSetter(nulls = Nulls.SKIP) — silently ignores explicit null.
* Always emits @JsonInclude(NON_NULL) so null fields are omitted from serialized output.
*/
@Test(description = "Scenario 3 – optional+non-nullable: null default with JsonSetter FAIL to block explicit nulls")
@Test(description = "Scenario 3 – optional+non-nullable, no openApiNullable: @JsonSetter(SKIP) + @JsonInclude(NON_NULL)")
public void requiredNullable_scenario3_optionalNonNullable() throws IOException {
Map<String, File> files = generateFromContract(
"src/test/resources/3_0/kotlin/required-nullable-4-states.yaml",
new HashMap<>());

Path modelFile = files.get("TestModel.kt").toPath();
// Must have @field:JsonSetter(nulls = Nulls.FAIL) annotation
assertFileContains(modelFile, "@field:JsonSetter(nulls = Nulls.FAIL)");
// Must have JsonSetter and Nulls imports
String content = Files.readString(modelFile);
int idx = content.indexOf("val optionalNonNullable:");
Assert.assertTrue(idx >= 0, "optionalNonNullable property must exist");
String context = content.substring(Math.max(0, idx - 200), idx);
Assert.assertTrue(context.contains("@field:JsonInclude(JsonInclude.Include.NON_NULL)"),
"optionalNonNullable must have @JsonInclude(NON_NULL) to omit null from serialized output");
Assert.assertTrue(context.contains("@field:JsonSetter(nulls = Nulls.SKIP)"),
"optionalNonNullable (no openApiNullable) should have @field:JsonSetter(nulls = Nulls.SKIP)");
Assert.assertFalse(context.contains("@field:JsonSetter(nulls = Nulls.FAIL)"),
"optionalNonNullable (no openApiNullable) must not have FAIL mode");
// Must have JsonSetter, Nulls, and JsonInclude imports
assertFileContains(modelFile,
"import com.fasterxml.jackson.annotation.JsonInclude",
"import com.fasterxml.jackson.annotation.JsonSetter",
"import com.fasterxml.jackson.annotation.Nulls");
// Must still be nullable type with null default
assertFileContains(modelFile, "val optionalNonNullable: kotlin.String? = null");
}

/**
* Scenario 3 with openApiNullable=true: required=false, nullable=false, no default.
* Uses strict @JsonSetter(nulls = Nulls.FAIL) and always emits @JsonInclude(NON_NULL).
*/
@Test(description = "Scenario 3 – optional+non-nullable with openApiNullable=true: @JsonSetter(FAIL) + @JsonInclude(NON_NULL)")
public void requiredNullable_scenario3_optionalNonNullable_withOpenApiNullable() throws IOException {
Map<String, File> files = generateFromContract(
"src/test/resources/3_0/kotlin/required-nullable-4-states.yaml",
Map.of(CodegenConstants.OPENAPI_NULLABLE, "true"));

Path modelFile = files.get("TestModel.kt").toPath();
String content = Files.readString(modelFile);
int idx = content.indexOf("val optionalNonNullable:");
Assert.assertTrue(idx >= 0, "optionalNonNullable property must exist");
String context = content.substring(Math.max(0, idx - 200), idx);
Assert.assertTrue(context.contains("@field:JsonInclude(JsonInclude.Include.NON_NULL)"),
"optionalNonNullable must have @JsonInclude(NON_NULL) to omit null from serialized output");
Assert.assertTrue(context.contains("@field:JsonSetter(nulls = Nulls.FAIL)"),
"optionalNonNullable should have @field:JsonSetter(FAIL) when openApiNullable=true");
// Must have all three imports
assertFileContains(modelFile,
"import com.fasterxml.jackson.annotation.JsonInclude",
"import com.fasterxml.jackson.annotation.JsonSetter",
"import com.fasterxml.jackson.annotation.Nulls");
// Must be nullable type with null default
assertFileContains(modelFile, "val optionalNonNullable: kotlin.String? = null");
// Must NOT be JsonNullable
assertFileNotContains(modelFile, "JsonNullable<kotlin.String>");
}

/**
* Scenario 3 with a defined default value: required=false, nullable=false, default="defaultValue", openApiNullable=false.
* Uses SKIP mode and @JsonInclude(NON_NULL) — null fields are omitted, protecting the default.
*/
@Test(description = "Scenario 3 – optional+non-nullable with default value: @JsonSetter(SKIP) + @JsonInclude(NON_NULL)")
public void requiredNullable_scenario3_optionalNonNullable_withDefault() throws IOException {
Map<String, File> files = generateFromContract(
"src/test/resources/3_0/kotlin/required-nullable-4-states.yaml",
new HashMap<>());

Path modelFile = files.get("TestModel.kt").toPath();
String content = Files.readString(modelFile);
int idx = content.indexOf("val optionalNonNullableWithDefault:");
Assert.assertTrue(idx >= 0, "optionalNonNullableWithDefault property must exist");
String context = content.substring(Math.max(0, idx - 200), idx);
Assert.assertTrue(context.contains("@field:JsonInclude(JsonInclude.Include.NON_NULL)"),
"optionalNonNullableWithDefault must have @JsonInclude(NON_NULL)");
Assert.assertTrue(context.contains("@field:JsonSetter(nulls = Nulls.SKIP)"),
"optionalNonNullableWithDefault should have @field:JsonSetter(nulls = Nulls.SKIP) when openApiNullable=false");
Assert.assertFalse(context.contains("@field:JsonSetter(nulls = Nulls.FAIL)"),
"optionalNonNullableWithDefault must not have FAIL mode when openApiNullable=false");
assertFileContains(modelFile,
"import com.fasterxml.jackson.annotation.JsonInclude",
"import com.fasterxml.jackson.annotation.JsonSetter",
"import com.fasterxml.jackson.annotation.Nulls");
}

/**
Expand Down Expand Up @@ -6473,15 +6536,16 @@ public void requiredNullable_scenario4_optionalNullable_withOpenApiNullable() th
}

/**
* Scenario 3 with Jackson 3 (Spring Boot 4): optional + non-nullable.
* Scenario 3 with Jackson 3 (Spring Boot 4) + openApiNullable=true: optional + non-nullable.
*
* @JsonSetter / Nulls imports should come from com.fasterxml.jackson.annotation
* (Jackson 3.x intentionally kept jackson-annotations at 2.x, same package).
*/
@Test(description = "Scenario 3 with Jackson 3: com.fasterxml.jackson.annotation.JsonSetter + Nulls imports")
@Test(description = "Scenario 3 with Jackson 3 + openApiNullable: com.fasterxml.jackson.annotation.JsonSetter + Nulls imports")
public void requiredNullable_scenario3_optionalNonNullable_withJackson3() throws IOException {
Map<String, Object> props = new HashMap<>();
props.put(KotlinSpringServerCodegen.USE_SPRING_BOOT4, "true");
props.put(CodegenConstants.OPENAPI_NULLABLE, "true");

Map<String, File> files = generateFromContract(
"src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", props);
Expand Down
Loading
Loading