diff --git a/docs/migration-guide.adoc b/docs/migration-guide.adoc index 591aebe82c91..d8cde8b16713 100644 --- a/docs/migration-guide.adoc +++ b/docs/migration-guide.adoc @@ -11,6 +11,38 @@ Another approach to find breaking changes is to look at issue and pull requests * link:https://github.com/OpenAPITools/openapi-generator/labels/Breaking%20change%20%28with%20fallback%29[Breaking change (with fallback)] * link:https://github.com/OpenAPITools/openapi-generator/labels/Breaking%20change%20%28without%20fallback%29[Breaking change (without fallback)] +=== From 7.24.x to 7.25.0 (java-spring / kotlin-spring) + +Version `7.24.0` (link:https://github.com/OpenAPITools/openapi-generator/pull/23993[#23993]) started emitting field-level Jackson annotations on model properties for the `spring` and `kotlin-spring` generators: + +* `@JsonInclude(...)` on properties (serialization), and +* `@JsonSetter(nulls = ...)` on optional non-nullable properties (deserialization). + +Field-level `@JsonInclude` overrides the project-wide `ObjectMapper` inclusion policy (for example `spring.jackson.default-property-inclusion=non_empty`), which silently changed the wire contract for projects that upgraded. See link:https://github.com/OpenAPITools/openapi-generator/issues/24401[#24401]. + +Starting with `7.25.0`, emission of these annotations is controlled by two independent, opt-in options. When an option is left **unset**, the generator defaults to the pre-`7.24.0` (`7.23.0`) behavior — *no* annotations are emitted — and logs a warning so the choice is visible even on transitive upgrades. Set the option explicitly to silence the warning. + +==== Serialization: `@JsonInclude` + +* `generateJsonIncludeAnnotations` (boolean) +** unset (default) → no `@JsonInclude` annotations; inclusion is governed entirely by the global `ObjectMapper` (a warning is logged). +** `false` → same weak behavior, warning silenced. +** `true` → emit spec-honest `@JsonInclude` annotations (required-field contract protection plus the optional non-nullable policy below). +* `optionalNonNullPropertyJsonInclude` (enum, default `NON_NULL`) → the policy emitted for optional non-nullable properties when `generateJsonIncludeAnnotations=true`. One of `NON_NULL` / `NON_EMPTY` / `NON_DEFAULT` / `NONE` (`NONE` emits nothing). + +A per-property override set via the `x-jackson-json-include-policy` vendor extension always wins, regardless of these flags. + +==== Deserialization: `@JsonSetter(nulls = ...)` + +* `generateJsonSetterNullsAnnotations` (boolean) +** unset (default) → no `@JsonSetter(nulls = ...)` annotations; null-handling is governed by the global `ObjectMapper` (a warning is logged). +** `false` → same weak behavior, warning silenced. +** `true` → emit `@JsonSetter(nulls = ...)` on optional non-nullable properties. + +==== Note + +`@JsonInclude(NON_ABSENT)` is no longer emitted on `JsonNullable` fields: the `JsonNullable` module already governs their inclusion, so the annotation was redundant. + === From 3.x to 4.0.0 Version `4.0.0` is a major release, which contains some breaking changes without fallback. diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java index ad507e2ff370..de86737a238b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java @@ -415,6 +415,15 @@ public static enum ENUM_PROPERTY_NAMING_TYPE {camelCase, PascalCase, snake_case, public static final String USE_VERTX_5 = "useVertx5"; public static final String USE_VERTX_5_DESC = "Setting this property to true will generate Vert.x 5 specific callbacks using Callables."; + public static final String OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE = "optionalNonNullPropertyJsonInclude"; + public static final String OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE_DESC = "The Jackson @JsonInclude policy to apply to optional, non-nullable properties (ignored unless generateJsonIncludeAnnotations is true)."; + + public static final String GENERATE_JSON_INCLUDE_ANNOTATIONS = "generateJsonIncludeAnnotations"; + public static final String GENERATE_JSON_INCLUDE_ANNOTATIONS_DESC = "Generate field-level @JsonInclude annotations controlling Jackson serialization inclusion. When unset or false, no @JsonInclude annotations are generated and the project-wide ObjectMapper policy applies."; + + public static final String GENERATE_JSON_SETTER_NULLS_ANNOTATIONS = "generateJsonSetterNullsAnnotations"; + public static final String GENERATE_JSON_SETTER_NULLS_ANNOTATIONS_DESC = "Generate field-level @JsonSetter(nulls = ...) annotations controlling Jackson deserialization null-handling for optional, non-nullable properties. When unset or false, no @JsonSetter annotations are generated and the project-wide ObjectMapper policy applies."; + public static final String DISALLOW_ADDITIONAL_PROPERTIES_IF_NOT_PRESENT = "disallowAdditionalPropertiesIfNotPresent"; public static final String DISALLOW_ADDITIONAL_PROPERTIES_IF_NOT_PRESENT_DESC = "If false, the 'additionalProperties' implementation (set to true by default) is compliant with the OAS and JSON schema specifications. " + diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java index 288ca9d0fb99..638bbe62b2fd 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java @@ -31,7 +31,8 @@ public enum VendorExtension { X_SIZE_MESSAGE("x-size-message", Arrays.asList(ExtensionLevel.FIELD, ExtensionLevel.OPERATION_PARAMETER), "Add this property whenever you need to customize the invalidation error message for the size or length of a variable", null), X_MINIMUM_MESSAGE("x-minimum-message", Arrays.asList(ExtensionLevel.FIELD, ExtensionLevel.OPERATION_PARAMETER), "Add this property whenever you need to customize the invalidation error message for the minimum value of a variable", null), X_MAXIMUM_MESSAGE("x-maximum-message", Arrays.asList(ExtensionLevel.FIELD, ExtensionLevel.OPERATION_PARAMETER), "Add this property whenever you need to customize the invalidation error message for the maximum value of a variable", null), - X_ZERO_BASED_ENUM("x-zero-based-enum", ExtensionLevel.MODEL, "When used on an enum, the index will not be generated and the default numbering will be used, zero-based", "false"); + X_ZERO_BASED_ENUM("x-zero-based-enum", ExtensionLevel.MODEL, "When used on an enum, the index will not be generated and the default numbering will be used, zero-based", "false"), + X_JACKSON_JSON_INCLUDE_POLICY("x-jackson-json-include-policy", ExtensionLevel.FIELD, "Manually override the resolved Jackson `@JsonInclude` policy for this property. Must be one of `ALWAYS`, `NON_NULL`, `NON_ABSENT`, `NON_EMPTY`, `NON_DEFAULT`, `USE_DEFAULTS`, `CUSTOM`, or `NONE` to emit no annotation. Always wins over the automatic required/nullable matrix and the `optionalNonNullPropertyJsonInclude` option.", "resolved automatically per the required/nullable matrix"); private final String name; private final List levels; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 4227feda5942..a895b420cf04 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -34,6 +34,7 @@ import org.openapitools.codegen.model.OperationMap; import org.openapitools.codegen.model.OperationsMap; import org.openapitools.codegen.templating.mustache.SpringHttpStatusLambda; +import org.openapitools.codegen.utils.JsonIncludePolicyUtils; import org.openapitools.codegen.utils.ModelUtils; import org.openapitools.codegen.utils.URLPathUtils; import org.slf4j.Logger; @@ -183,6 +184,10 @@ public String getDescription() { @Setter private boolean useEnumValueInterface = false; private String valuedEnumClassName = "ValuedEnum"; @Setter private boolean suspendFunctions = false; + @Getter @Setter private String optionalNonNullPropertyJsonInclude = "NON_NULL"; + // Tri-state: null = unset (weak default + warning), Boolean.FALSE = weak (muted), Boolean.TRUE = strict emission. + @Getter @Setter private Boolean generateJsonIncludeAnnotations = null; + @Getter @Setter private Boolean generateJsonSetterNullsAnnotations = null; @Getter @Setter private boolean openApiNullable = false; @Getter @Setter protected boolean useDeductionForOneOfInterfaces = false; @@ -314,6 +319,31 @@ public KotlinSpringServerCodegen() { substituteGenericPagedModel); addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject); addSwitch(SUSPEND_FUNCTIONS, "Whether to generate suspend functions for API operations. Useful for Spring MVC with Kotlin coroutines without requiring the full reactive stack.", suspendFunctions); + + CliOption optionalNonNullPropertyJsonIncludeOpt = CliOption.newString(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE, + "The Jackson @JsonInclude policy emitted for optional, non-nullable model properties when " + + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS + " is true. " + + "NONE emits no annotation, deferring fully to the global ObjectMapper inclusion policy."); + optionalNonNullPropertyJsonIncludeOpt.addEnum("NON_NULL", "Omit the property when its value is null (default, spec-safe for non-nullable fields)."); + optionalNonNullPropertyJsonIncludeOpt.addEnum("NON_EMPTY", "Omit the property when its value is null or considered empty."); + optionalNonNullPropertyJsonIncludeOpt.addEnum("NON_DEFAULT", "Omit the property when its value equals the default."); + optionalNonNullPropertyJsonIncludeOpt.addEnum("NONE", "Emit no @JsonInclude annotation; defer to the global ObjectMapper."); + optionalNonNullPropertyJsonIncludeOpt.setDefault(optionalNonNullPropertyJsonInclude); + cliOptions.add(optionalNonNullPropertyJsonIncludeOpt); + + addSwitch(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, + "Whether to generate policy @JsonInclude annotations on model properties. When true, emits " + + "spec-honest annotations (required-field protection and the optional non-nullable policy from " + + CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE + "). When false, none are generated and the global " + + "ObjectMapper owns inclusion. When left unset it defaults to false (7.23.0-equivalent output) and " + + "logs a warning; set it explicitly to silence the warning. A per-property override set via the " + + "`x-jackson-json-include-policy` vendor extension is always honored regardless of this flag.", false); + addSwitch(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, + "Whether to generate @JsonSetter(nulls = ...) annotations on optional non-nullable model properties. " + + "When true, emits @JsonSetter (Nulls.FAIL when openApiNullable is true, otherwise Nulls.SKIP) so " + + "an explicit null in the payload is handled explicitly. When false, none are generated and " + + "deserialization null-handling defers to the global ObjectMapper. When left unset it defaults to " + + "false (7.23.0-equivalent output) and logs a warning; set it explicitly to silence the warning.", false); 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, @@ -733,6 +763,38 @@ public void processOpts() { } writePropertyBack(SUSPEND_FUNCTIONS, suspendFunctions); + if (additionalProperties.containsKey(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS)) { + this.setGenerateJsonIncludeAnnotations(convertPropertyToBoolean(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS)); + } + writePropertyBack(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, Boolean.TRUE.equals(generateJsonIncludeAnnotations)); + if (additionalProperties.containsKey(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS)) { + this.setGenerateJsonSetterNullsAnnotations(convertPropertyToBoolean(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS)); + } + writePropertyBack(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, Boolean.TRUE.equals(generateJsonSetterNullsAnnotations)); + if (additionalProperties.containsKey(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE)) { + this.setOptionalNonNullPropertyJsonInclude(additionalProperties.get(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE).toString()); + } + this.optionalNonNullPropertyJsonInclude = JsonIncludePolicyUtils.normalizeJsonIncludePolicy( + this.optionalNonNullPropertyJsonInclude, CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE); + writePropertyBack(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE, optionalNonNullPropertyJsonInclude); + if (generateJsonIncludeAnnotations == null) { + LOGGER.warn("'{}' is not set. Defaulting to false: no @JsonInclude annotations are generated and property " + + "inclusion is governed entirely by the global ObjectMapper (7.23.0-equivalent output). " + + "Set '{}=false' to keep this behavior and silence this warning, or '{}=true' to emit spec-honest " + + "@JsonInclude annotations (see '{}'). Note: before 7.24.0 released output had no field-level " + + "@JsonInclude, so leaving this unset preserves that behavior.", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE); + } + if (generateJsonSetterNullsAnnotations == null) { + LOGGER.warn("'{}' is not set. Defaulting to false: no @JsonSetter(nulls = ...) annotations are generated and " + + "deserialization null-handling is governed entirely by the global ObjectMapper (7.23.0-equivalent " + + "output). Set '{}=false' to keep this behavior and silence this warning, or '{}=true' to emit " + + "@JsonSetter(nulls = ...) on optional non-nullable fields.", + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS); + } + if (additionalProperties.containsKey(BEAN_QUALIFIERS) && library.equals(SPRING_BOOT)) { this.setBeanQualifiers(convertPropertyToBoolean(BEAN_QUALIFIERS)); } @@ -1280,12 +1342,11 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert property.example = null; } - // Scenario 3: optional + non-nullable → always emit @JsonSetter to handle explicit JSON nulls. + // Scenario 3: optional + non-nullable → emit @JsonSetter to handle explicit JSON nulls, only when + // generateJsonSetterNullsAnnotations is explicitly enabled. // 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) { + if (Boolean.TRUE.equals(generateJsonSetterNullsAnnotations) && !property.required && !property.isNullable) { if (openApiNullable) { property.vendorExtensions.put("x-has-json-setter-nulls-fail", true); } else { @@ -1293,7 +1354,6 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert } model.imports.add("JsonSetter"); model.imports.add("Nulls"); - model.imports.add("JsonInclude"); } // Scenario 4: optional + nullable with openApiNullable → use JsonNullable = JsonNullable.undefined() @@ -1303,6 +1363,8 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert model.imports.add("JsonNullable"); } + resolveJsonIncludePolicy(model, property); + //Add imports for Jackson if (!model.isEnum) { model.imports.add("JsonProperty"); @@ -1322,6 +1384,50 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert } } + /** + * Resolve the {@code @JsonInclude} policy into the single universal + * {@code x-jackson-json-include-policy} vendor extension the template emits. Precedence: + *
    + *
  1. A value set directly on the property (manual override in the spec) always wins.
  2. + *
  3. Otherwise, when {@code generateJsonIncludeAnnotations=true}, apply the automatic matrix: + *
      + *
    • required (nullable or not) → {@code ALWAYS} (Kotlin type prevents null for non-nullable; + * explicit null is valid for nullable and must be serialized)
    • + *
    • optional & non-nullable → {@code optionalNonNullPropertyJsonInclude} + * (default {@code NON_NULL}, {@code NONE} = omit)
    • + *
    • optional & nullable → none (JsonNullable module already governs inclusion)
    • + *
    + *
  4. + *
+ */ + private void resolveJsonIncludePolicy(CodegenModel model, CodegenProperty property) { + if (property.vendorExtensions.containsKey(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName())) { + String manualPolicy = JsonIncludePolicyUtils.resolveManualJsonIncludePolicy( + property.vendorExtensions.get(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName()), VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName()); + if (manualPolicy != null) { + property.vendorExtensions.put(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName(), manualPolicy); + model.imports.add("JsonInclude"); + } else { + // NONE / empty means "emit nothing"; drop the extension so the template renders no annotation. + property.vendorExtensions.remove(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName()); + } + return; + } + if (!Boolean.TRUE.equals(generateJsonIncludeAnnotations)) { + return; + } + String policy = null; + if (property.required) { + policy = "ALWAYS"; + } else if (!property.isNullable) { + policy = optionalNonNullPropertyJsonInclude; + } + if (JsonIncludePolicyUtils.isJsonIncludePolicyEmitted(policy)) { + property.vendorExtensions.put(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName(), policy); + model.imports.add("JsonInclude"); + } + } + @Override public void addImportsToOneOfInterface(List> imports) { if (additionalProperties.containsKey("jackson")) { @@ -1466,9 +1572,9 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { for (ModelMap mo : objs.getModels()) { CodegenModel cm = mo.getModel(); for (CodegenProperty var : cm.optionalVars) { - // Scenario 3: optional + non-nullable → always emit @JsonSetter and @JsonInclude(NON_NULL). + // Scenario 3: optional + non-nullable → emit @JsonSetter when generateJsonSetterNullsAnnotations is enabled. // openApiNullable=true: Nulls.FAIL (strict). openApiNullable=false: Nulls.SKIP (lenient). - if (!var.required && !var.isNullable) { + if (Boolean.TRUE.equals(generateJsonSetterNullsAnnotations) && !var.required && !var.isNullable) { if (openApiNullable) { var.vendorExtensions.put("x-has-json-setter-nulls-fail", true); } else { @@ -1479,6 +1585,10 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { if (openApiNullable && !var.required && var.isNullable) { var.vendorExtensions.put("x-is-jackson-optional-nullable", true); } + resolveJsonIncludePolicy(cm, var); + } + for (CodegenProperty var : cm.requiredVars) { + resolveJsonIncludePolicy(cm, var); } } @@ -1782,6 +1892,7 @@ public List getSupportedVendorExtensions() { extensions.add(VendorExtension.X_KOTLIN_IMPLEMENTS); extensions.add(VendorExtension.X_KOTLIN_IMPLEMENTS_FIELDS); extensions.add(VendorExtension.X_SPRING_PAGINATED); + extensions.add(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY); return extensions; } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 1ab10fd19d09..9ed49f55c9f9 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -40,6 +40,7 @@ import org.openapitools.codegen.templating.mustache.SplitStringLambda; import org.openapitools.codegen.templating.mustache.SpringHttpStatusLambda; import org.openapitools.codegen.templating.mustache.TrimWhitespaceLambda; +import org.openapitools.codegen.utils.JsonIncludePolicyUtils; import org.openapitools.codegen.utils.ModelUtils; import org.openapitools.codegen.utils.ProcessUtils; import org.openapitools.codegen.utils.URLPathUtils; @@ -161,6 +162,10 @@ public enum RequestMappingMode { @Setter protected boolean apiFirst = false; protected boolean useOptional = false; @Setter protected boolean useSealed = false; + @Getter @Setter protected String optionalNonNullPropertyJsonInclude = "NON_NULL"; + // Tri-state: null = unset (weak default + warning), Boolean.FALSE = weak (muted), Boolean.TRUE = strict emission. + @Getter @Setter protected Boolean generateJsonIncludeAnnotations = null; + @Getter @Setter protected Boolean generateJsonSetterNullsAnnotations = null; @Setter protected boolean virtualService = false; @Setter protected boolean hateoas = false; @Setter protected boolean returnSuccessCode = false; @@ -284,6 +289,31 @@ public SpringCodegen() { "Use Bean Validation Impl. to perform BeanValidation", performBeanValidation)); cliOptions.add(CliOption.newBoolean(USE_SEALED, "Whether to generate sealed model interfaces and classes")); + + CliOption optionalNonNullPropertyJsonIncludeOpt = CliOption.newString(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE, + "The Jackson @JsonInclude policy emitted for optional, non-nullable model properties when " + + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS + " is true. " + + "NONE emits no annotation, deferring fully to the global ObjectMapper inclusion policy."); + optionalNonNullPropertyJsonIncludeOpt.addEnum("NON_NULL", "Omit the property when its value is null (default, spec-safe for non-nullable fields)."); + optionalNonNullPropertyJsonIncludeOpt.addEnum("NON_EMPTY", "Omit the property when its value is null or considered empty."); + optionalNonNullPropertyJsonIncludeOpt.addEnum("NON_DEFAULT", "Omit the property when its value equals the default."); + optionalNonNullPropertyJsonIncludeOpt.addEnum("NONE", "Emit no @JsonInclude annotation; defer to the global ObjectMapper."); + optionalNonNullPropertyJsonIncludeOpt.setDefault(optionalNonNullPropertyJsonInclude); + cliOptions.add(optionalNonNullPropertyJsonIncludeOpt); + + cliOptions.add(CliOption.newBoolean(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, + "Whether to generate policy @JsonInclude annotations on model properties. When true, emits " + + "spec-honest annotations (required-field protection and the optional non-nullable policy from " + + CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE + "). When false, none are generated and the global " + + "ObjectMapper owns inclusion. When left unset it defaults to false (7.23.0-equivalent output) and " + + "logs a warning; set it explicitly to silence the warning. A per-property override set via the " + + "`x-jackson-json-include-policy` vendor extension is always honored regardless of this flag.", false)); + cliOptions.add(CliOption.newBoolean(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, + "Whether to generate @JsonSetter(nulls = ...) annotations on optional non-nullable model properties. " + + "When true, emits @JsonSetter so an explicit null in the payload does not overwrite the field. " + + "When false, none are generated and deserialization null-handling defers to the global ObjectMapper. " + + "When left unset it defaults to false (7.23.0-equivalent output) and logs a warning; set it " + + "explicitly to silence the warning.", false)); cliOptions.add(CliOption.newBoolean(API_FIRST, "Generate the API from the OAI spec at server compile time (API first approach)", apiFirst)); cliOptions @@ -558,6 +588,33 @@ public void processOpts() { convertPropertyToBooleanAndWriteBack(RETURN_SUCCESS_CODE, this::setReturnSuccessCode); convertPropertyToBooleanAndWriteBack(USE_SWAGGER_UI, this::setUseSwaggerUI); convertPropertyToBooleanAndWriteBack(USE_SEALED, this::setUseSealed); + convertPropertyToBooleanAndWriteBack(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, this::setGenerateJsonIncludeAnnotations); + convertPropertyToBooleanAndWriteBack(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, this::setGenerateJsonSetterNullsAnnotations); + if (additionalProperties.containsKey(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE)) { + this.setOptionalNonNullPropertyJsonInclude(additionalProperties.get(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE).toString()); + } + this.optionalNonNullPropertyJsonInclude = JsonIncludePolicyUtils.normalizeJsonIncludePolicy( + this.optionalNonNullPropertyJsonInclude, CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE); + additionalProperties.put(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE, optionalNonNullPropertyJsonInclude); + if (jackson) { + if (generateJsonIncludeAnnotations == null) { + LOGGER.warn("'{}' is not set. Defaulting to false: no @JsonInclude annotations are generated and property " + + "inclusion is governed entirely by the global ObjectMapper (7.23.0-equivalent output). " + + "Set '{}=false' to keep this behavior and silence this warning, or '{}=true' to emit spec-honest " + + "@JsonInclude annotations (see '{}'). Note: before 7.24.0 released output had no field-level " + + "@JsonInclude, so leaving this unset preserves that behavior.", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE); + } + if (generateJsonSetterNullsAnnotations == null) { + LOGGER.warn("'{}' is not set. Defaulting to false: no @JsonSetter(nulls = ...) annotations are generated and " + + "deserialization null-handling is governed entirely by the global ObjectMapper (7.23.0-equivalent " + + "output). Set '{}=false' to keep this behavior and silence this warning, or '{}=true' to emit " + + "@JsonSetter(nulls = ...) on optional non-nullable fields.", + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS); + } + } if (DocumentationProvider.NONE.equals(getDocumentationProvider())) { this.setUseSwaggerUI(false); } @@ -1213,24 +1270,48 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert 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"); + // Optional + non-nullable, when openApiNullable=false: add @JsonSetter(nulls = Nulls.SKIP) on the + // setter so an explicit null in the payload does not overwrite the field's default. Only emitted when + // generateJsonSetterNullsAnnotations is explicitly enabled; otherwise deserialization defers to the mapper. + if (Boolean.TRUE.equals(generateJsonSetterNullsAnnotations) && !property.required && !property.isNullable && !openApiNullable) { + property.vendorExtensions.put("x-has-json-setter-nulls-skip", true); + model.imports.add("JsonSetter"); + model.imports.add("Nulls"); + } + + // Resolve the @JsonInclude policy into the single universal x-jackson-json-include-policy vendor + // extension the template emits. Precedence: + // 1. A value set directly on the property (manual override in the spec) always wins. + // 2. Otherwise, when generateJsonIncludeAnnotations=true, apply the automatic matrix: + // required & non-nullable -> NON_NULL (contract protection; omit rather than emit invalid null) + // required & nullable -> ALWAYS (explicit null is valid and must be serialized) + // optional & non-nullable -> optionalNonNullPropertyJsonInclude (default NON_NULL, NONE = omit) + // optional & nullable -> none (JsonNullable module already governs inclusion) + if (property.vendorExtensions.containsKey(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName())) { + String manualPolicy = JsonIncludePolicyUtils.resolveManualJsonIncludePolicy( + property.vendorExtensions.get(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName()), VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName()); + if (manualPolicy != null) { + property.vendorExtensions.put(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName(), manualPolicy); + model.imports.add("JsonInclude"); + } else { + // NONE / empty means "emit nothing"; drop the extension so the template renders no annotation. + property.vendorExtensions.remove(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName()); + } + } else if (Boolean.TRUE.equals(generateJsonIncludeAnnotations)) { + String policy = null; + if (property.required) { + policy = property.isNullable ? "ALWAYS" : "NON_NULL"; + } else if (!property.isNullable) { + policy = optionalNonNullPropertyJsonInclude; + } + if (JsonIncludePolicyUtils.isJsonIncludePolicyEmitted(policy)) { + property.vendorExtensions.put(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName(), policy); + model.imports.add("JsonInclude"); } - } - // Optional + nullable with openApiNullable: emit @JsonInclude(NON_ABSENT) so that - // JsonNullable.undefined() is excluded from serialized output. - if (openApiNullable && !property.required && property.isNullable) { - model.imports.add("JsonInclude"); } } + @Override public CodegenModel fromModel(String name, Schema model) { CodegenModel codegenModel = super.fromModel(name, model); @@ -1565,6 +1646,7 @@ public List getSupportedVendorExtensions() { extensions.add(VendorExtension.X_MINIMUM_MESSAGE); extensions.add(VendorExtension.X_MAXIMUM_MESSAGE); extensions.add(VendorExtension.X_SPRING_API_VERSION); + extensions.add(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY); return extensions; } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/JsonIncludePolicyUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/JsonIncludePolicyUtils.java new file mode 100644 index 000000000000..e0f0d0b75f85 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/JsonIncludePolicyUtils.java @@ -0,0 +1,106 @@ +/* + * Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openapitools.codegen.utils; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** + * Shared resolution/validation logic for the {@code x-jackson-json-include-policy} vendor extension and the + * {@code optionalNonNullPropertyJsonInclude} config option, used by both the {@code spring} and + * {@code kotlin-spring} generators (issue #24401). + */ +public final class JsonIncludePolicyUtils { + + /** Sentinel value meaning "emit no {@code @JsonInclude} annotation". */ + public static final String NONE = "NONE"; + + /** + * Valid {@code com.fasterxml.jackson.annotation.JsonInclude.Include} constant names accepted for a + * manual per-property {@code x-jackson-json-include-policy} override. {@code NONE} is a sentinel + * meaning "emit no annotation" and is handled separately (the extension is dropped). + */ + private static final Set VALID_JSON_INCLUDE_CONSTANTS = new HashSet<>(Arrays.asList( + "ALWAYS", "NON_NULL", "NON_ABSENT", "NON_EMPTY", "NON_DEFAULT", "USE_DEFAULTS", "CUSTOM")); + + /** Valid values for the {@code optionalNonNullPropertyJsonInclude} config option. */ + private static final Set VALID_OPTIONAL_NON_NULL_POLICIES = new HashSet<>(Arrays.asList( + "NON_NULL", "NON_EMPTY", "NON_DEFAULT", "NONE")); + + private JsonIncludePolicyUtils() { + } + + /** + * Whether the given policy value (a manual override, or an already-resolved automatic policy) should + * result in an emitted {@code @JsonInclude} annotation. {@code null}, blank/whitespace-only, and the + * {@code NONE} sentinel (after trimming, case-insensitive) all mean "emit nothing". + */ + public static boolean isJsonIncludePolicyEmitted(Object policy) { + if (policy == null) { + return false; + } + String trimmed = policy.toString().trim(); + return !trimmed.isEmpty() && !NONE.equalsIgnoreCase(trimmed); + } + + /** + * Validate and normalize a manual per-property {@code x-jackson-json-include-policy} override. + * + * @param rawPolicy the raw vendor extension value set directly on the property in the spec + * @param extensionName the vendor extension key, used in the error message (kept generator-agnostic) + * @return the normalized (upper-case) policy to emit, or {@code null} when the override means + * "emit no annotation" ({@code NONE}/blank), in which case the caller must drop the extension. + * @throws IllegalArgumentException when the override is not a valid {@code JsonInclude.Include} value. + */ + public static String resolveManualJsonIncludePolicy(Object rawPolicy, String extensionName) { + if (!isJsonIncludePolicyEmitted(rawPolicy)) { + return null; + } + String normalized = rawPolicy.toString().trim().toUpperCase(Locale.ROOT); + if (!VALID_JSON_INCLUDE_CONSTANTS.contains(normalized)) { + throw new IllegalArgumentException(extensionName + + " must be a valid com.fasterxml.jackson.annotation.JsonInclude.Include value " + + "(ALWAYS, NON_NULL, NON_ABSENT, NON_EMPTY, NON_DEFAULT, USE_DEFAULTS, CUSTOM), or NONE to emit " + + "no annotation, but was: " + rawPolicy); + } + return normalized; + } + + /** + * Validate and normalize the {@code optionalNonNullPropertyJsonInclude} config option value. + * + * @param policy the raw config option value (may be {@code null}, in which case the default + * {@code NON_NULL} is returned) + * @param optionName the config option name, used in the error message (kept generator-agnostic) + * @return the normalized (upper-case) policy: one of {@code NON_NULL}, {@code NON_EMPTY}, + * {@code NON_DEFAULT}, {@code NONE}. + * @throws IllegalArgumentException when the value is not one of the supported policies. + */ + public static String normalizeJsonIncludePolicy(String policy, String optionName) { + if (policy == null) { + return "NON_NULL"; + } + String normalized = policy.trim().toUpperCase(Locale.ROOT); + if (!VALID_OPTIONAL_NON_NULL_POLICIES.contains(normalized)) { + throw new IllegalArgumentException(optionName + + " must be one of NON_NULL, NON_EMPTY, NON_DEFAULT, NONE but was: " + policy); + } + return normalized; + } +} diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache index 6dd2e6da00d2..b7f28efc82e9 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache @@ -62,14 +62,12 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}} {{{.}}} {{/vendorExtensions.x-field-extra-annotation}} {{#jackson}} - {{^required}} - {{^isNullable}} - @JsonInclude(JsonInclude.Include.NON_NULL) - {{/isNullable}} - {{/required}} - {{#vendorExtensions.x-is-jackson-optional-nullable}} - @JsonInclude(JsonInclude.Include.NON_ABSENT) - {{/vendorExtensions.x-is-jackson-optional-nullable}} + {{#vendorExtensions.x-jackson-json-include-policy}} + @JsonInclude(JsonInclude.Include.{{{vendorExtensions.x-jackson-json-include-policy}}}) + {{/vendorExtensions.x-jackson-json-include-policy}} + {{#vendorExtensions.x-has-json-setter-nulls-skip}} + @JsonSetter(nulls = Nulls.SKIP) + {{/vendorExtensions.x-has-json-setter-nulls-skip}} {{/jackson}} {{#deprecated}} @Deprecated @@ -252,11 +250,6 @@ 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}} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassOptVar.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassOptVar.mustache index 900c2e3f4662..78380ea41ac9 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassOptVar.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassOptVar.mustache @@ -2,9 +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}}{{^isNullable}} - @field:JsonInclude(JsonInclude.Include.NON_NULL){{/isNullable}}{{#vendorExtensions.x-is-jackson-optional-nullable}} - @field:JsonInclude(JsonInclude.Include.NON_ABSENT){{/vendorExtensions.x-is-jackson-optional-nullable}}{{#vendorExtensions.x-has-json-setter-nulls-skip}} + {{{.}}}{{/vendorExtensions.x-field-extra-annotation}}{{#vendorExtensions.x-jackson-json-include-policy}} + @field:JsonInclude(JsonInclude.Include.{{{vendorExtensions.x-jackson-json-include-policy}}}){{/vendorExtensions.x-jackson-json-include-policy}}{{#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}} @param:JsonProperty("{{{baseName}}}") diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassReqVar.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassReqVar.mustache index 2f07705c6dd0..e299d4582a3c 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassReqVar.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassReqVar.mustache @@ -1,6 +1,7 @@ {{#useBeanValidation}}{{>beanValidation}}{{>beanValidationModel}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}} @Schema({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeInNormalString}}{{{.}}}{{/lambdaEscapeInNormalString}}{{/lambdaRemoveLineBreak}}", {{/example}}required = true, {{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}description = "{{{description}}}"){{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}} @ApiModelProperty({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeInNormalString}}{{{.}}}{{/lambdaEscapeInNormalString}}{{/lambdaRemoveLineBreak}}", {{/example}}required = true, {{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}value = "{{{description}}}"){{/swagger1AnnotationLibrary}}{{#vendorExtensions.x-field-extra-annotation}} - {{{.}}}{{/vendorExtensions.x-field-extra-annotation}} + {{{.}}}{{/vendorExtensions.x-field-extra-annotation}}{{#vendorExtensions.x-jackson-json-include-policy}} + @field:JsonInclude(JsonInclude.Include.{{{vendorExtensions.x-jackson-json-include-policy}}}){{/vendorExtensions.x-jackson-json-include-policy}} @param:JsonProperty("{{{baseName}}}") @get:JsonProperty("{{{baseName}}}", required = true){{#isInherited}} override{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}}?{{/isNullable}}{{#defaultValue}} = {{^isNumber}}{{{defaultValue}}}{{/isNumber}}{{#isNumber}}{{{dataType}}}("{{{defaultValue}}}"){{/isNumber}}{{/defaultValue}} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index 2193ee5c9f9f..42ea848788da 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -8294,57 +8294,333 @@ void issue24003() throws IOException { } /** - * Scenario 4 (openApiNullable=true): optional+nullable field must carry - * {@code @JsonInclude(JsonInclude.Include.NON_ABSENT)} so that Jackson - * excludes {@code JsonNullable.undefined()} from serialized output. + * Issue #24401: the {@code @JsonInclude(NON_ABSENT)} annotation must no longer be emitted for + * {@code JsonNullable} fields, as the JsonNullable module already governs their inclusion. */ @Test - void optionalNullableField_withOpenApiNullable_hasNonAbsentAnnotation() throws IOException { + void optionalNullableField_withOpenApiNullable_hasNoJsonIncludeAnnotation() throws IOException { Map files = generateFromContract( "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", SPRING_BOOT, Map.of(CodegenConstants.OPENAPI_NULLABLE, "true")); Path modelFile = files.get("TestModel.java").toPath(); - // NON_ABSENT must be present — only optionalNullable (JsonNullable) gets this annotation - assertFileContains(modelFile, "@JsonInclude(JsonInclude.Include.NON_ABSENT)"); - // JsonNullable field must be present - assertFileContains(modelFile, "private JsonNullable optionalNullable"); - // NON_NULL must also be present (for optionalNonNullable fields) - assertFileContains(modelFile, "@JsonInclude(JsonInclude.Include.NON_NULL)"); - assertFileContains(modelFile, "import com.fasterxml.jackson.annotation.JsonInclude"); + assertFileNotContains(modelFile, "@JsonInclude(JsonInclude.Include.NON_ABSENT)"); + JavaFileAssert.assertThat(files.get("TestModel.java")) + .assertProperty("optionalNullable").withType("JsonNullable") + .assertPropertyAnnotations().doesNotContainWithName("JsonInclude"); } /** - * Without openApiNullable the optional+nullable field is a plain nullable type — - * no {@code @JsonInclude(NON_ABSENT)} should be emitted. + * Issue #24401 (safe-but-noisy): with no flags set, the generator defaults to weak/7.23.0 behavior — + * NO policy {@code @JsonInclude} or {@code @JsonSetter(nulls)} annotations are emitted, deferring + * entirely to the global ObjectMapper. */ @Test - void optionalNullableField_withoutOpenApiNullable_hasNoNonAbsentAnnotation() throws IOException { + void jsonInclude_unset_emitsNoPolicyAnnotations() throws IOException { Map files = generateFromContract( "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", SPRING_BOOT, - Map.of(CodegenConstants.OPENAPI_NULLABLE, "false")); + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true")); Path modelFile = files.get("TestModel.java").toPath(); - // Without openApiNullable the field is String optionalNullable, not JsonNullable - assertFileNotContains(modelFile, "@JsonInclude(JsonInclude.Include.NON_ABSENT)"); + assertFileNotContains(modelFile, "@JsonInclude("); + assertFileNotContains(modelFile, "@JsonSetter("); } /** - * Optional+non-nullable fields must still have {@code @JsonInclude(NON_NULL)} regardless - * of the openApiNullable setting. + * Issue #24401: default matrix for JAVA-SPRING when {@code generateJsonIncludeAnnotations=true} + * (openApiNullable=true). required non-nullable -> NON_NULL, required nullable -> ALWAYS, + * optional non-nullable -> NON_NULL (default policy), optional nullable -> no annotation. */ @Test - void optionalNonNullableField_alwaysHasNonNullAnnotation() throws IOException { + void jsonInclude_defaultMatrix() throws IOException { Map files = generateFromContract( "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", SPRING_BOOT, - Map.of(CodegenConstants.OPENAPI_NULLABLE, "true")); + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true")); + + JavaFileAssert.assertThat(files.get("TestModel.java")) + .assertProperty("requiredNonNullable").assertPropertyAnnotations() + .containsWithNameAndAttributes("JsonInclude", Map.of("value", "JsonInclude.Include.NON_NULL")).toProperty().toType() + .assertProperty("requiredNullable").assertPropertyAnnotations() + .containsWithNameAndAttributes("JsonInclude", Map.of("value", "JsonInclude.Include.ALWAYS")).toProperty().toType() + .assertProperty("optionalNonNullable").assertPropertyAnnotations() + .containsWithNameAndAttributes("JsonInclude", Map.of("value", "JsonInclude.Include.NON_NULL")).toProperty().toType() + .assertProperty("optionalNullable").assertPropertyAnnotations() + .doesNotContainWithName("JsonInclude"); + } + + /** + * Issue #24401 (safe-but-noisy): {@code generateJsonSetterNullsAnnotations=true} emits + * {@code @JsonSetter(nulls = Nulls.SKIP)} on optional non-nullable fields (openApiNullable=false); + * leaving it unset emits none. + */ + @Test + void jsonSetterNulls_generateFlag_controlsEmission() throws IOException { + Map withFlag = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.OPENAPI_NULLABLE, "false", + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true")); + assertFileContains(withFlag.get("OptionalNonNullable.java").toPath(), "@JsonSetter(nulls = Nulls.SKIP)"); + + Map unset = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.OPENAPI_NULLABLE, "false")); + assertFileNotContains(unset.get("OptionalNonNullable.java").toPath(), "@JsonSetter("); + } + + /** + * Issue #24401 regression: when Lombok generates the setter ({@code lombok.Setter}), the manual + * setter method (and the {@code @JsonSetter} annotation that used to live only on it) is skipped + * entirely. {@code @JsonSetter(nulls = Nulls.SKIP)} must be emitted on the field itself so it is + * still honored by Jackson even though no explicit setter method is generated. + */ + @Test + void jsonSetterNulls_generateFlag_appliesWithLombokSetter() throws IOException { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(CodegenConstants.OPENAPI_NULLABLE, "false"); + additionalProperties.put(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true"); + additionalProperties.put(AbstractJavaCodegen.ADDITIONAL_MODEL_TYPE_ANNOTATIONS, "@lombok.Getter;@lombok.Setter"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + SPRING_BOOT, + additionalProperties); + + JavaFileAssert.assertThat(files.get("OptionalNonNullable.java")) + .hasNoMethod("setOptionalNonNullable"); + assertFileContains(files.get("OptionalNonNullable.java").toPath(), "@JsonSetter(nulls = Nulls.SKIP)"); + } + + /** + * Issue #24401: {@code optionalNonNullPropertyJsonInclude} changes the policy emitted for + * optional non-nullable properties (when {@code generateJsonIncludeAnnotations=true}). + */ + @Test + void jsonInclude_optionalNonNullPolicy_nonEmpty() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true", + CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE, "NON_EMPTY")); + + JavaFileAssert.assertThat(files.get("TestModel.java")) + .assertProperty("optionalNonNullable").assertPropertyAnnotations() + .containsWithNameAndAttributes("JsonInclude", Map.of("value", "JsonInclude.Include.NON_EMPTY")).toProperty().toType() + // required-field protection is unaffected by the optional policy + .assertProperty("requiredNonNullable").assertPropertyAnnotations() + .containsWithNameAndAttributes("JsonInclude", Map.of("value", "JsonInclude.Include.NON_NULL")); + } + + /** + * Issue #24401: {@code optionalNonNullPropertyJsonInclude=NONE} emits no annotation on optional + * non-nullable properties, deferring to the global ObjectMapper. Required-field protection stays. + */ + @Test + void jsonInclude_optionalNonNullPolicy_none() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true", + CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE, "NONE")); + + JavaFileAssert.assertThat(files.get("TestModel.java")) + .assertProperty("optionalNonNullable").assertPropertyAnnotations() + .doesNotContainWithName("JsonInclude").toProperty().toType() + .assertProperty("requiredNonNullable").assertPropertyAnnotations() + .containsWithNameAndAttributes("JsonInclude", Map.of("value", "JsonInclude.Include.NON_NULL")); + } + + /** + * Issue #24401: {@code generateJsonIncludeAnnotations=false} removes ALL policy @JsonInclude + * annotations, including the required-field protection, letting the global ObjectMapper win. + */ + @Test + void jsonInclude_generateJsonIncludeAnnotations_false() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "false")); Path modelFile = files.get("TestModel.java").toPath(); - assertFileContains(modelFile, "@JsonInclude(JsonInclude.Include.NON_NULL)"); - assertFileContains(modelFile, "private String optionalNonNullable"); + assertFileNotContains(modelFile, "@JsonInclude("); + } + + /** + * Issue #24401: a manual per-property {@code x-jackson-json-include-policy} vendor extension + * always overrides the automatic behavior, even when {@code generateJsonIncludeAnnotations=false}. + */ + @Test + void jsonInclude_manualOverride_winsOverGenerateFlag() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_override.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "false")); + + JavaFileAssert.assertThat(files.get("TestModel.java")) + .assertProperty("overridden").assertPropertyAnnotations() + .containsWithNameAndAttributes("JsonInclude", Map.of("value", "JsonInclude.Include.NON_EMPTY")).toProperty().toType() + // no automatic annotation on the non-overridden optional field + .assertProperty("plain").assertPropertyAnnotations() + .doesNotContainWithName("JsonInclude"); + } + + /** + * Issue #24401: with one property per schema, each generated model must import exactly the + * Jackson annotations its single property needs — no more, no less. + */ + @Test + void jsonInclude_perSchemaImports() throws IOException { + final String jsonInclude = "com.fasterxml.jackson.annotation.JsonInclude"; + final String jsonSetter = "com.fasterxml.jackson.annotation.JsonSetter"; + final String nulls = "com.fasterxml.jackson.annotation.Nulls"; + final String jsonNullable = "org.openapitools.jackson.nullable.JsonNullable"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true")); + + // required non-nullable -> @JsonInclude(NON_NULL); no setter machinery + JavaFileAssert.assertThat(files.get("RequiredNonNullable.java")) + .hasImports(jsonInclude).hasNoImports(jsonSetter, nulls); + // required nullable -> @JsonInclude(ALWAYS) + JavaFileAssert.assertThat(files.get("RequiredNullable.java")) + .hasImports(jsonInclude).hasNoImports(jsonSetter, nulls); + // optional non-nullable with openApiNullable=true -> @JsonInclude(NON_NULL), no @JsonSetter + JavaFileAssert.assertThat(files.get("OptionalNonNullable.java")) + .hasImports(jsonInclude).hasNoImports(jsonSetter, nulls); + // optional nullable with openApiNullable=true -> JsonNullable, NO @JsonInclude + JavaFileAssert.assertThat(files.get("OptionalNullable.java")) + .hasImports(jsonNullable).hasNoImports(jsonInclude, jsonSetter, nulls); + } + + /** + * Issue #24401: with openApiNullable=false, optional non-nullable adds @JsonSetter(Nulls.SKIP); + * optional nullable is a plain type needing none of the Jackson import machinery. + */ + @Test + void jsonInclude_perSchemaImports_withoutOpenApiNullable() throws IOException { + final String jsonInclude = "com.fasterxml.jackson.annotation.JsonInclude"; + final String jsonSetter = "com.fasterxml.jackson.annotation.JsonSetter"; + final String nulls = "com.fasterxml.jackson.annotation.Nulls"; + final String jsonNullable = "org.openapitools.jackson.nullable.JsonNullable"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.OPENAPI_NULLABLE, "false", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true", + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true")); + + // optional non-nullable -> @JsonInclude(NON_NULL) + @JsonSetter(Nulls.SKIP) + JavaFileAssert.assertThat(files.get("OptionalNonNullable.java")) + .hasImports(jsonInclude, jsonSetter, nulls).hasNoImports(jsonNullable); + // optional nullable (plain String) -> no policy annotation, no import machinery + JavaFileAssert.assertThat(files.get("OptionalNullable.java")) + .hasNoImports(jsonInclude, jsonSetter, nulls, jsonNullable); + } + + /** + * Issue #24401: even with {@code generateJsonIncludeAnnotations=false}, a manual per-property + * vendor extension must still emit its annotation AND the JsonInclude import must be present. + */ + @Test + void jsonInclude_manualOverride_emitsImport_whenAnnotationsDisabled() throws IOException { + final String jsonInclude = "com.fasterxml.jackson.annotation.JsonInclude"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "false")); + + JavaFileAssert.assertThat(files.get("ManualOverride.java")) + .hasImports(jsonInclude) + .assertProperty("value").assertPropertyAnnotations() + .containsWithNameAndAttributes("JsonInclude", Map.of("value", "JsonInclude.Include.NON_EMPTY")); + // A schema without the override must not import JsonInclude when annotations are disabled + JavaFileAssert.assertThat(files.get("OptionalNonNullable.java")).hasNoImports(jsonInclude); + } + + /** + * Issue #24401: a forced override on an optional+nullable ({@code JsonNullable}) property must be + * respected — the annotation is emitted and the JsonInclude import is added, even though the + * automatic path emits nothing for JsonNullable fields. + */ + @Test + void jsonInclude_forcedOverride_onJsonNullable_emitsAnnotationAndImport() throws IOException { + final String jsonInclude = "com.fasterxml.jackson.annotation.JsonInclude"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true")); + + JavaFileAssert.assertThat(files.get("ForcedOnJsonNullable.java")) + .hasImports(jsonInclude) + .assertProperty("value").withType("JsonNullable").assertPropertyAnnotations() + .containsWithNameAndAttributes("JsonInclude", Map.of("value", "JsonInclude.Include.NON_NULL")); + } + + /** + * Issue #24401: a manual per-property override of {@code NONE} means "emit no annotation". Neither the + * {@code @JsonInclude} annotation nor its import may be generated, otherwise the output fails to compile. + */ + @Test + void jsonInclude_manualOverride_none_emitsNoAnnotationOrImport() throws IOException { + final String jsonInclude = "com.fasterxml.jackson.annotation.JsonInclude"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true")); + + JavaFileAssert.assertThat(files.get("ManualNone.java")) + .hasNoImports(jsonInclude) + .assertProperty("value").assertPropertyAnnotations() + .doesNotContainWithName("JsonInclude"); + } + + /** + * Issue #24401: a whitespace-padded {@code NONE} override must be treated identically to a bare + * {@code NONE} — the sentinel comparison must trim before checking, otherwise it falls through to + * validation and generation fails for a value that should simply suppress the annotation. + */ + @Test + void jsonInclude_manualOverride_paddedNone_emitsNoAnnotationOrImport() throws IOException { + final String jsonInclude = "com.fasterxml.jackson.annotation.JsonInclude"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true")); + + JavaFileAssert.assertThat(files.get("ManualNonePadded.java")) + .hasNoImports(jsonInclude) + .assertProperty("value").assertPropertyAnnotations() + .doesNotContainWithName("JsonInclude"); + } + + /** + * Issue #24401: an invalid manual per-property override must fail fast with an actionable error + * during generation rather than emitting uncompilable Java. + */ + @Test + void jsonInclude_manualOverride_invalid_failsWithActionableError() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_invalid_override.yaml", + SPRING_BOOT, + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true"))) + .hasStackTraceContaining("x-jackson-json-include-policy") + .hasStackTraceContaining("NOT_A_REAL_POLICY"); } @Test diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index c0d2cb18e3f0..e96f9a00f8d3 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -6540,7 +6540,8 @@ public void requiredNullable_scenario2_requiredNullable() throws IOException { public void requiredNullable_scenario3_optionalNonNullable() throws IOException { Map files = generateFromContract( "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", - new HashMap<>()); + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true", + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true")); Path modelFile = files.get("TestModel.kt").toPath(); String content = Files.readString(modelFile); @@ -6570,7 +6571,9 @@ public void requiredNullable_scenario3_optionalNonNullable() throws IOException public void requiredNullable_scenario3_optionalNonNullable_withOpenApiNullable() throws IOException { Map files = generateFromContract( "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", - Map.of(CodegenConstants.OPENAPI_NULLABLE, "true")); + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true", + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true")); Path modelFile = files.get("TestModel.kt").toPath(); String content = Files.readString(modelFile); @@ -6598,7 +6601,8 @@ public void requiredNullable_scenario3_optionalNonNullable_withOpenApiNullable() public void requiredNullable_scenario3_optionalNonNullable_withDefault() throws IOException { Map files = generateFromContract( "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", - new HashMap<>()); + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true", + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true")); Path modelFile = files.get("TestModel.kt").toPath(); String content = Files.readString(modelFile); @@ -6674,6 +6678,7 @@ public void requiredNullable_scenario3_optionalNonNullable_withJackson3() throws Map props = new HashMap<>(); props.put(KotlinSpringServerCodegen.USE_SPRING_BOOT4, "true"); props.put(CodegenConstants.OPENAPI_NULLABLE, "true"); + props.put(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true"); Map files = generateFromContract( "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", props); @@ -6690,28 +6695,245 @@ public void requiredNullable_scenario3_optionalNonNullable_withJackson3() throws } /** - * Scenario 4 (openApiNullable=true): optional+nullable field must carry - * {@code @field:JsonInclude(JsonInclude.Include.NON_ABSENT)} so that Jackson - * excludes {@code JsonNullable.undefined()} from serialized output. + * Issue #24401: optional+nullable ({@code JsonNullable}) fields must no longer carry + * {@code @field:JsonInclude(NON_ABSENT)} — the JsonNullable module already governs their inclusion. */ - @Test(description = "Scenario 4 – optional+nullable with openApiNullable=true: @JsonInclude(NON_ABSENT) guards undefined from serialization") - public void requiredNullable_scenario4_optionalNullable_hasNonAbsentAnnotation() throws IOException { + @Test(description = "Issue #24401 – optional+nullable with openApiNullable=true: no @JsonInclude annotation") + public void requiredNullable_scenario4_optionalNullable_hasNoJsonIncludeAnnotation() throws IOException { Map 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 optionalNullable:"); - Assert.assertTrue(idx >= 0, "optionalNullable property must exist"); - // Annotations appear before the val declaration - String context = content.substring(Math.max(0, idx - 300), idx); - Assert.assertTrue(context.contains("@field:JsonInclude(JsonInclude.Include.NON_ABSENT)"), - "optionalNullable must have @field:JsonInclude(NON_ABSENT) to suppress JsonNullable.undefined() from output"); - // Must NOT have NON_NULL — that annotation is only for non-nullable optional fields - Assert.assertFalse(context.contains("@field:JsonInclude(JsonInclude.Include.NON_NULL)"), - "optionalNullable must NOT have @field:JsonInclude(NON_NULL); only non-nullable optionals use NON_NULL"); - assertFileContains(modelFile, "import com.fasterxml.jackson.annotation.JsonInclude"); + // NON_ABSENT must no longer be emitted anywhere + assertFileNotContains(modelFile, "@field:JsonInclude(JsonInclude.Include.NON_ABSENT)"); + // optionalNullable must still be a JsonNullable wrapper + assertFileContains(modelFile, "JsonNullable"); + } + + /** + * Issue #24401 (safe-but-noisy): with no flags set, kotlin-spring defaults to weak/7.23.0 behavior — + * NO policy {@code @field:JsonInclude} or {@code @field:JsonSetter(nulls)} annotations are emitted. + */ + @Test(description = "Issue #24401 – unset flags emit no policy annotations (kotlin-spring)") + public void jsonInclude_unset_emitsNoPolicyAnnotations() throws IOException { + Map 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(); + assertFileNotContains(modelFile, "@field:JsonInclude("); + assertFileNotContains(modelFile, "@field:JsonSetter("); + } + + /** + * Issue #24401: default matrix for kotlin-spring. required fields -> ALWAYS, + * optional non-nullable -> NON_NULL (default policy), optional nullable -> no annotation. + */ + @Test(description = "Issue #24401 – default @JsonInclude matrix (kotlin-spring)") + public void jsonInclude_kotlinMatrix_default() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true")); + + String content = Files.readString(files.get("TestModel.kt").toPath()); + Assert.assertTrue(jsonIncludeBlockFor(content, "requiredNonNullable").contains("@field:JsonInclude(JsonInclude.Include.ALWAYS)"), + "required non-nullable must be ALWAYS"); + Assert.assertTrue(jsonIncludeBlockFor(content, "requiredNullable").contains("@field:JsonInclude(JsonInclude.Include.ALWAYS)"), + "required nullable must be ALWAYS"); + Assert.assertTrue(jsonIncludeBlockFor(content, "optionalNonNullable").contains("@field:JsonInclude(JsonInclude.Include.NON_NULL)"), + "optional non-nullable must be NON_NULL by default"); + Assert.assertFalse(jsonIncludeBlockFor(content, "optionalNullable").contains("@field:JsonInclude"), + "optional nullable must have no @JsonInclude annotation"); + } + + /** + * Issue #24401: {@code optionalNonNullPropertyJsonInclude=NONE} omits the annotation on optional + * non-nullable properties while keeping the required-field protection. + */ + @Test(description = "Issue #24401 – optionalNonNullPropertyJsonInclude=NONE (kotlin-spring)") + public void jsonInclude_optionalNonNullPolicy_none() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true", + CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE, "NONE")); + + String content = Files.readString(files.get("TestModel.kt").toPath()); + Assert.assertFalse(jsonIncludeBlockFor(content, "optionalNonNullable").contains("@field:JsonInclude"), + "optional non-nullable must have no annotation when policy is NONE"); + Assert.assertTrue(jsonIncludeBlockFor(content, "requiredNonNullable").contains("@field:JsonInclude(JsonInclude.Include.ALWAYS)"), + "required-field protection must still be present"); + } + + /** + * Issue #24401: {@code generateJsonIncludeAnnotations=false} removes ALL policy @JsonInclude + * annotations, including required-field protection. + */ + @Test(description = "Issue #24401 – generateJsonIncludeAnnotations=false removes all policy annotations (kotlin-spring)") + public void jsonInclude_generateJsonIncludeAnnotations_false() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml", + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "false")); + + assertFileNotContains(files.get("TestModel.kt").toPath(), "@field:JsonInclude("); + } + + /** + * Issue #24401: a manual per-property {@code x-jackson-json-include-policy} vendor extension always + * overrides the automatic behavior, even when {@code generateJsonIncludeAnnotations=false}. + */ + @Test(description = "Issue #24401 – manual vendor-extension override wins (kotlin-spring)") + public void jsonInclude_manualOverride_winsOverGenerateFlag() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_override.yaml", + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "false")); + + String content = Files.readString(files.get("TestModel.kt").toPath()); + Assert.assertTrue(jsonIncludeBlockFor(content, "overridden").contains("@field:JsonInclude(JsonInclude.Include.NON_EMPTY)"), + "manual override must be honored even with generateJsonIncludeAnnotations=false"); + Assert.assertFalse(jsonIncludeBlockFor(content, "plain").contains("@field:JsonInclude"), + "non-overridden field must have no annotation when generateJsonIncludeAnnotations=false"); + } + + /** + * Returns the source region isolating a single property's annotations, bounded by the previous + * property's {@code @get:JsonProperty} marker, so @field:JsonInclude checks are property-specific. + */ + private static String jsonIncludeBlockFor(String content, String propName) { + int end = content.indexOf("@get:JsonProperty(\"" + propName + "\""); + Assert.assertTrue(end >= 0, propName + " property must exist"); + int prev = content.lastIndexOf("@get:JsonProperty(", end - 1); + return content.substring(Math.max(0, prev), end); + } + + /** + * Issue #24401: with one property per schema, each generated model must import exactly the Jackson + * annotations its single property needs. openApiNullable=true so optional nullable uses JsonNullable. + */ + @Test(description = "Issue #24401 – per-schema import isolation (kotlin-spring)") + public void jsonInclude_perSchemaImports() throws IOException { + final String jsonInclude = "import com.fasterxml.jackson.annotation.JsonInclude"; + final String jsonSetter = "import com.fasterxml.jackson.annotation.JsonSetter"; + final String nulls = "import com.fasterxml.jackson.annotation.Nulls"; + final String jsonNullable = "import org.openapitools.jackson.nullable.JsonNullable"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true", + CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true", + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true")); + + // required non-nullable -> @field:JsonInclude(ALWAYS); no setter/nullable machinery + Path requiredNonNullable = files.get("RequiredNonNullable.kt").toPath(); + assertFileContains(requiredNonNullable, jsonInclude); + assertFileNotContains(requiredNonNullable, jsonSetter, nulls, jsonNullable); + // required nullable -> @field:JsonInclude(ALWAYS) + Path requiredNullable = files.get("RequiredNullable.kt").toPath(); + assertFileContains(requiredNullable, jsonInclude); + assertFileNotContains(requiredNullable, jsonSetter, nulls, jsonNullable); + // optional non-nullable -> @field:JsonInclude(NON_NULL) + @field:JsonSetter(Nulls.FAIL) + Path optionalNonNullable = files.get("OptionalNonNullable.kt").toPath(); + assertFileContains(optionalNonNullable, jsonInclude, jsonSetter, nulls); + assertFileNotContains(optionalNonNullable, jsonNullable); + // optional nullable with openApiNullable=true -> JsonNullable, NO @field:JsonInclude + Path optionalNullable = files.get("OptionalNullable.kt").toPath(); + assertFileContains(optionalNullable, jsonNullable); + assertFileNotContains(optionalNullable, jsonInclude, jsonSetter, nulls); + } + + /** + * Issue #24401: even with {@code generateJsonIncludeAnnotations=false}, a manual per-property vendor + * extension must still emit its annotation AND the JsonInclude import must be present. + */ + @Test(description = "Issue #24401 – manual override emits import even when annotations disabled (kotlin-spring)") + public void jsonInclude_manualOverride_emitsImport_whenAnnotationsDisabled() throws IOException { + final String jsonInclude = "import com.fasterxml.jackson.annotation.JsonInclude"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "false")); + + Path manualOverride = files.get("ManualOverride.kt").toPath(); + assertFileContains(manualOverride, jsonInclude); + Assert.assertTrue(jsonIncludeBlockFor(Files.readString(manualOverride), "value") + .contains("@field:JsonInclude(JsonInclude.Include.NON_EMPTY)"), + "manual override must be emitted even when generateJsonIncludeAnnotations=false"); + // A schema without the override must not import JsonInclude when annotations are disabled + assertFileNotContains(files.get("OptionalNonNullable.kt").toPath(), jsonInclude); + } + + /** + * Issue #24401: a forced override on an optional+nullable ({@code JsonNullable}) property must be + * respected — the annotation is emitted and the JsonInclude import is added. + */ + @Test(description = "Issue #24401 – forced override on JsonNullable emits annotation and import (kotlin-spring)") + public void jsonInclude_forcedOverride_onJsonNullable_emitsAnnotationAndImport() throws IOException { + final String jsonInclude = "import com.fasterxml.jackson.annotation.JsonInclude"; + final String jsonNullable = "import org.openapitools.jackson.nullable.JsonNullable"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + Map.of(CodegenConstants.OPENAPI_NULLABLE, "true")); + + Path forced = files.get("ForcedOnJsonNullable.kt").toPath(); + assertFileContains(forced, jsonInclude, jsonNullable, "JsonNullable"); + Assert.assertTrue(jsonIncludeBlockFor(Files.readString(forced), "value") + .contains("@field:JsonInclude(JsonInclude.Include.NON_NULL)"), + "forced override on JsonNullable field must be emitted"); + } + + /** + * Issue #24401: a manual per-property override of {@code NONE} means "emit no annotation". Neither the + * {@code @field:JsonInclude} annotation nor its import may be generated, otherwise the output fails to compile. + */ + @Test(description = "Issue #24401 – manual NONE override emits no annotation or import (kotlin-spring)") + public void jsonInclude_manualOverride_none_emitsNoAnnotationOrImport() throws IOException { + final String jsonInclude = "import com.fasterxml.jackson.annotation.JsonInclude"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true")); + + Path manualNone = files.get("ManualNone.kt").toPath(); + assertFileNotContains(manualNone, jsonInclude); + Assert.assertFalse(Files.readString(manualNone).contains("@field:JsonInclude"), + "manual NONE override must emit no @field:JsonInclude annotation"); + } + + /** + * Issue #24401: a whitespace-padded {@code NONE} override must be treated identically to a bare + * {@code NONE} — the sentinel comparison must trim before checking, otherwise it falls through to + * validation and generation fails for a value that should simply suppress the annotation. + */ + @Test(description = "Issue #24401 – padded NONE override emits no annotation or import (kotlin-spring)") + public void jsonInclude_manualOverride_paddedNone_emitsNoAnnotationOrImport() throws IOException { + final String jsonInclude = "import com.fasterxml.jackson.annotation.JsonInclude"; + + Map files = generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml", + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true")); + + Path manualNonePadded = files.get("ManualNonePadded.kt").toPath(); + assertFileNotContains(manualNonePadded, jsonInclude); + Assert.assertFalse(Files.readString(manualNonePadded).contains("@field:JsonInclude"), + "padded NONE override must emit no @field:JsonInclude annotation"); + } + + /** + * Issue #24401: an invalid manual per-property override must fail fast with an actionable error + * during generation rather than emitting uncompilable Kotlin. + */ + @Test(description = "Issue #24401 – invalid manual override fails fast (kotlin-spring)") + public void jsonInclude_manualOverride_invalid_failsWithActionableError() { + Throwable thrown = Assert.expectThrows(Throwable.class, () -> generateFromContract( + "src/test/resources/3_0/spring/issue_24401_json_include_invalid_override.yaml", + Map.of(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true"))); + + java.io.StringWriter sw = new java.io.StringWriter(); + thrown.printStackTrace(new java.io.PrintWriter(sw)); + String trace = sw.toString(); + Assert.assertTrue(trace.contains("x-jackson-json-include-policy") && trace.contains("NOT_A_REAL_POLICY"), + "expected an actionable error naming the invalid policy, but got: " + trace); } /** @@ -6998,6 +7220,7 @@ public void testIssue24139NullableRefNoJsonSetterNullsFail() throws IOException Map additionalProperties = new HashMap<>(); additionalProperties.put("useBeanValidation", true); additionalProperties.put("openApiNullable", "true"); + additionalProperties.put(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true"); Map files = generateFromContract( "src/test/resources/3_1/issue_24139.yaml", diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_invalid_override.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_invalid_override.yaml new file mode 100644 index 000000000000..39d6b3ed12eb --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_invalid_override.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.0 +info: + title: JsonInclude invalid manual override test + version: 1.0.0 + description: > + A single schema whose property carries an invalid x-jackson-json-include-policy value. + Generation must fail fast with an actionable error rather than emitting uncompilable output. +paths: + /test: + post: + operationId: testPost + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidOverride' + responses: + '200': + description: OK +components: + schemas: + InvalidOverride: + type: object + properties: + value: + type: string + nullable: false + x-jackson-json-include-policy: NOT_A_REAL_POLICY diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_override.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_override.yaml new file mode 100644 index 000000000000..c39028982365 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_override.yaml @@ -0,0 +1,30 @@ +openapi: 3.0.0 +info: + title: JsonInclude manual override test + version: 1.0.0 +paths: + /test: + post: + operationId: testPost + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TestModel' + responses: + '200': + description: OK +components: + schemas: + TestModel: + type: object + properties: + # Manual per-property override via the universal vendor extension. + overridden: + type: string + nullable: false + x-jackson-json-include-policy: NON_EMPTY + # No override: with generateJsonIncludeAnnotations=false this must get no annotation. + plain: + type: string + nullable: false diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml new file mode 100644 index 000000000000..5e10c8cf438e --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml @@ -0,0 +1,99 @@ +openapi: 3.0.0 +info: + title: JsonInclude per-schema import isolation test + version: 1.0.0 + description: > + Each schema holds exactly one property covering one required/optional x + nullable/non-nullable combination. Isolating a single case per model ensures the + generated imports (JsonInclude, JsonSetter, Nulls, JsonNullable) are correct for + every case on their own, without another property masking a missing import. +paths: + /test: + post: + operationId: testPost + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RequiredNonNullable' + responses: + '200': + description: OK +components: + schemas: + # required + non-nullable + RequiredNonNullable: + type: object + required: + - value + properties: + value: + type: string + nullable: false + # required + nullable + RequiredNullable: + type: object + required: + - value + properties: + value: + type: string + nullable: true + # optional + non-nullable + OptionalNonNullable: + type: object + properties: + value: + type: string + nullable: false + # optional + non-nullable with a default value + OptionalNonNullableWithDefault: + type: object + properties: + value: + type: string + nullable: false + default: "defaultValue" + # optional + nullable + OptionalNullable: + type: object + properties: + value: + type: string + nullable: true + # manual per-property override: even with generateJsonIncludeAnnotations=false the + # annotation (and its import) must still be emitted from the vendor extension. + ManualOverride: + type: object + properties: + value: + type: string + nullable: false + x-jackson-json-include-policy: NON_EMPTY + # forced override on an optional + nullable (JsonNullable) property: the automatic path emits + # nothing here, but an explicit vendor extension must be respected and its import added. + ForcedOnJsonNullable: + type: object + properties: + value: + type: string + nullable: true + x-jackson-json-include-policy: NON_NULL + # manual per-property override of NONE: the sentinel means "emit no annotation", so neither the + # @JsonInclude annotation nor its import must be generated (otherwise the output fails to compile). + ManualNone: + type: object + properties: + value: + type: string + nullable: false + x-jackson-json-include-policy: NONE + # manual per-property override of NONE padded with whitespace: must be treated identically to a + # bare NONE (normalization must trim before comparing), i.e. emit no annotation, no validation error. + ManualNonePadded: + type: object + properties: + value: + type: string + nullable: false + x-jackson-json-include-policy: " NONE "