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
Jump to file
Failed to load files.
Loading
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 @@ -1266,10 +1271,15 @@ 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).
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");
}
Expand Down Expand Up @@ -1444,9 +1454,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.
// 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,14 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert
if (model.getVendorExtensions().containsKey("x-jackson-optional-nullable-helpers")) {
model.imports.add("Arrays");
}

// Optional + non-nullable with openApiNullable=false → @JsonSetter(nulls = Nulls.SKIP).
// Silently ignores explicit JSON null (lenient), which protects any defined default from being overridden.
if (!openApiNullable && !Boolean.TRUE.equals(property.required) && !Boolean.TRUE.equals(property.isNullable)) {
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 @@ -242,6 +242,9 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
{{#vendorExtensions.x-setter-extra-annotation}}
{{{vendorExtensions.x-setter-extra-annotation}}}
{{/vendorExtensions.x-setter-extra-annotation}}
{{#vendorExtensions.x-has-json-setter-nulls-skip}}
@JsonSetter(nulls = Nulls.SKIP)
{{/vendorExtensions.x-has-json-setter-nulls-skip}}
{{#deprecated}}
@Deprecated
{{/deprecated}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@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}}{{#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,84 @@ 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,
* preventing it from overriding any default value, while still accepting missing fields.
*/
@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(nulls=Nulls.SKIP) annotation present")
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)");
String content = Files.readString(modelFile);
// Check property-level context: the 200 chars preceding "val optionalNonNullable:" must contain @JsonSetter(nulls = Nulls.SKIP)
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: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 and Nulls imports
assertFileContains(modelFile,
"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.
* When openApiNullable is enabled, strict null semantics are requested and the annotation IS generated.
*/
@Test(description = "Scenario 3 – optional+non-nullable with openApiNullable=true: @JsonSetter FAIL annotation present")
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);
// Check property-level context: the 200 chars preceding "val optionalNonNullable:" must contain @JsonSetter
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:JsonSetter(nulls = Nulls.FAIL)"),
"optionalNonNullable should have @field:JsonSetter when openApiNullable=true");
// Must have JsonSetter and Nulls imports
assertFileContains(modelFile,
"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.
* With openApiNullable=false, uses SKIP mode — silently ignores explicit null, protecting the default.
*/
@Test(description = "Scenario 3 – optional+non-nullable with default value: @JsonSetter(nulls=Nulls.SKIP) protects the default")
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);
// The property with a default must have @field:JsonSetter(nulls = Nulls.SKIP) — lenient protection
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: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");
// Imports must be present
assertFileContains(modelFile,
"import com.fasterxml.jackson.annotation.JsonSetter",
"import com.fasterxml.jackson.annotation.Nulls");
}

/**
Expand Down Expand Up @@ -6473,15 +6531,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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ components:
optionalNonNullable:
type: string
nullable: false
# Scenario 3 with default: required=false, nullable=false, default value set => null must be blocked
optionalNonNullableWithDefault:
type: string
nullable: false
default: "defaultValue"
# Scenario 4: required=false, nullable=true => 3-state (JsonNullable)
optionalNullable:
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.fasterxml.jackson.annotation.Nulls;
import org.springframework.lang.Nullable;
import java.time.OffsetDateTime;
import jakarta.validation.constraints.NotNull;
Expand Down Expand Up @@ -40,6 +42,7 @@ public CategoryDto id(@Nullable Long id) {
return id;
}

@JsonSetter(nulls = Nulls.SKIP)
@JsonProperty("id")
public void setId(@Nullable Long id) {
this.id = id;
Expand All @@ -60,6 +63,7 @@ public CategoryDto name(@Nullable String name) {
return name;
}

@JsonSetter(nulls = Nulls.SKIP)
@JsonProperty("name")
public void setName(@Nullable String name) {
this.name = name;
Expand Down
Loading
Loading