>) var.getAllowableValues().get(ENUM_VARS)) {
- if (defaultValue.equals(enumVars.get(ENUM_NAME))) {
+ for (EnumVarMap enumVars : EnumUtils.getEnumVars(var.getAllowableValues())) {
+ if (defaultValue.equals(enumVars.getEnumName())) {
// update default to use the string directly instead of enum string
- var.defaultValue = (String) enumVars.get(ENUM_VALUE);
+ var.defaultValue = (String) enumVars.getEnumValue();
}
}
}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java
index 3344db943a5b..2133145a608b 100755
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java
@@ -58,9 +58,6 @@
import org.openapitools.codegen.templating.mustache.ReplaceAllLambda;
import static java.util.Collections.sort;
-import static org.openapitools.codegen.CodegenConstants.ENUM_NAME;
-import static org.openapitools.codegen.utils.EnumUtils.getEnumVars;
-import static org.openapitools.codegen.utils.EnumUtils.hasEnumVars;
/**
* Mustache templates are located in
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 b05fc5df0432..d2c9e61a130b 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,7 +34,10 @@
import org.openapitools.codegen.model.OperationMap;
import org.openapitools.codegen.model.OperationsMap;
import org.openapitools.codegen.templating.mustache.SpringHttpStatusLambda;
+import org.openapitools.codegen.utils.JsonAnnotationPolicyUtils;
+import org.openapitools.codegen.utils.JsonIncludePolicy;
import org.openapitools.codegen.utils.ModelUtils;
+import org.openapitools.codegen.utils.TriStateBoolean;
import org.openapitools.codegen.utils.URLPathUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -182,6 +185,10 @@ public enum RequestMappingMode {
@Setter private boolean useEnumValueInterface = false;
private String valuedEnumClassName = "ValuedEnum";
@Setter private boolean suspendFunctions = false;
+ @Getter @Setter private JsonIncludePolicy optionalNonNullPropertyJsonInclude = JsonIncludePolicy.NON_NULL;
+ @Getter @Setter private JsonAnnotationPolicyUtils.JsonSetterNullsMode optionalNonNullPropertyJsonSetterNulls = null;
+ @Getter @Setter private TriStateBoolean generateJsonIncludeAnnotations = TriStateBoolean.UNSET;
+ @Getter @Setter private TriStateBoolean generateJsonSetterNullsAnnotations = TriStateBoolean.UNSET;
@Getter @Setter private boolean openApiNullable = false;
@Getter @Setter
protected boolean useDeductionForOneOfInterfaces = false;
@@ -315,6 +322,30 @@ 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,
+ JsonAnnotationPolicyUtils.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE_DESC);
+ for (JsonIncludePolicy policy : JsonIncludePolicy.OPTIONAL_NON_NULL_POLICIES) {
+ optionalNonNullPropertyJsonIncludeOpt.addEnum(policy.name(), policy.getDescription());
+ }
+ optionalNonNullPropertyJsonIncludeOpt.setDefault(optionalNonNullPropertyJsonInclude.name());
+ cliOptions.add(optionalNonNullPropertyJsonIncludeOpt);
+
+ CliOption optionalNonNullPropertyJsonSetterNullsOpt = CliOption.newString(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS,
+ JsonAnnotationPolicyUtils.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS_DESC);
+ optionalNonNullPropertyJsonSetterNullsOpt.addEnum(JsonAnnotationPolicyUtils.JsonSetterNullsMode.SKIP.name(),
+ "Emit @JsonSetter(nulls = Nulls.SKIP): silently ignore an explicit JSON null, keeping the field's default.");
+ optionalNonNullPropertyJsonSetterNullsOpt.addEnum(JsonAnnotationPolicyUtils.JsonSetterNullsMode.FAIL.name(),
+ "Emit @JsonSetter(nulls = Nulls.FAIL): reject an explicit JSON null.");
+ cliOptions.add(optionalNonNullPropertyJsonSetterNullsOpt);
+ addSwitch(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS,
+ JsonAnnotationPolicyUtils.GENERATE_JSON_INCLUDE_ANNOTATIONS_DESC, 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,
@@ -735,6 +766,26 @@ public void processOpts() {
}
writePropertyBack(SUSPEND_FUNCTIONS, suspendFunctions);
+ if (additionalProperties.containsKey(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS)) {
+ this.generateJsonIncludeAnnotations = TriStateBoolean.fromNullableBoolean(convertPropertyToBoolean(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS));
+ }
+ writePropertyBack(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, generateJsonIncludeAnnotations.isTrue());
+ if (additionalProperties.containsKey(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS)) {
+ this.generateJsonSetterNullsAnnotations = TriStateBoolean.fromNullableBoolean(convertPropertyToBoolean(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS));
+ }
+ writePropertyBack(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, generateJsonSetterNullsAnnotations.isTrue());
+ this.optionalNonNullPropertyJsonInclude = JsonAnnotationPolicyUtils.resolveOptionalNonNullPropertyJsonInclude(
+ additionalProperties, optionalNonNullPropertyJsonInclude);
+ writePropertyBack(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE, optionalNonNullPropertyJsonInclude.name());
+ this.optionalNonNullPropertyJsonSetterNulls = JsonAnnotationPolicyUtils.resolveOptionalNonNullPropertyJsonSetterNulls(
+ additionalProperties, optionalNonNullPropertyJsonSetterNulls);
+ if (optionalNonNullPropertyJsonSetterNulls != null) {
+ writePropertyBack(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, optionalNonNullPropertyJsonSetterNulls.name());
+ }
+ JsonAnnotationPolicyUtils.warnIfUnset(LOGGER, generateJsonIncludeAnnotations, generateJsonSetterNullsAnnotations);
+ JsonAnnotationPolicyUtils.warnIfJsonSetterNullsDefaultRisky(LOGGER, generateJsonSetterNullsAnnotations,
+ optionalNonNullPropertyJsonSetterNulls, openApiNullable, true);
+
if (additionalProperties.containsKey(BEAN_QUALIFIERS) && library.equals(SPRING_BOOT)) {
this.setBeanQualifiers(convertPropertyToBoolean(BEAN_QUALIFIERS));
}
@@ -1281,21 +1332,12 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert
property.example = null;
}
- // Scenario 3: optional + non-nullable → always emit @JsonSetter to handle explicit JSON nulls.
- // When openApiNullable=true: Nulls.FAIL → reject explicit null (strict PATCH semantics).
- // When openApiNullable=false: Nulls.SKIP → silently ignore explicit null (lenient, protects defaults).
- // Always emit @JsonInclude(NON_NULL) so null fields are omitted from serialized output regardless
- // of who is deserializing on the other end — closer to spec, avoids round-trip failures.
- if (!property.required && !property.isNullable) {
- if (openApiNullable) {
- property.vendorExtensions.put("x-has-json-setter-nulls-fail", true);
- } else {
- property.vendorExtensions.put("x-has-json-setter-nulls-skip", true);
- }
- model.imports.add("JsonSetter");
- model.imports.add("Nulls");
- model.imports.add("JsonInclude");
- }
+ // Emit @JsonSetter(nulls = ...) for optional, non-nullable properties (and honor a per-property
+ // x-jackson-json-setter-nulls override) when enabled; see JsonAnnotationPolicyUtils#resolveJsonSetterNulls
+ // for the full precedence/SKIP/FAIL rules. kotlin-spring's default path supports both SKIP and FAIL
+ // (failModeSupported=true), unlike spring.
+ JsonAnnotationPolicyUtils.resolveJsonSetterNulls(model, property, generateJsonSetterNullsAnnotations,
+ optionalNonNullPropertyJsonSetterNulls, openApiNullable, true);
// Scenario 4: optional + nullable with openApiNullable → use JsonNullable = JsonNullable.undefined()
// so callers can distinguish between a missing key and an explicitly provided null.
@@ -1304,6 +1346,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");
@@ -1323,6 +1367,18 @@ 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. See
+ * {@link JsonAnnotationPolicyUtils#resolveJsonIncludePolicy} for the full precedence rules; here, required
+ * properties (nullable or not) always resolve to {@code ALWAYS} since Kotlin's non-nullable types can
+ * never hold null.
+ */
+ private void resolveJsonIncludePolicy(CodegenModel model, CodegenProperty property) {
+ JsonAnnotationPolicyUtils.resolveJsonIncludePolicy(model, property, generateJsonIncludeAnnotations,
+ optionalNonNullPropertyJsonInclude, JsonIncludePolicy.ALWAYS, JsonIncludePolicy.ALWAYS);
+ }
+
@Override
public void addImportsToOneOfInterface(List> imports) {
if (additionalProperties.containsKey("jackson")) {
@@ -1540,19 +1596,22 @@ 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).
- // openApiNullable=true: Nulls.FAIL (strict). openApiNullable=false: Nulls.SKIP (lenient).
- if (!var.required && !var.isNullable) {
- if (openApiNullable) {
- var.vendorExtensions.put("x-has-json-setter-nulls-fail", true);
- } else {
- var.vendorExtensions.put("x-has-json-setter-nulls-skip", true);
- }
- }
+ // Scenario 3: optional + non-nullable → emit @JsonSetter when generateJsonSetterNullsAnnotations is enabled,
+ // honoring any per-property x-jackson-json-setter-nulls override and the optionalNonNullPropertyJsonSetterNulls option.
+ JsonAnnotationPolicyUtils.resolveJsonSetterNulls(cm, var, generateJsonSetterNullsAnnotations,
+ optionalNonNullPropertyJsonSetterNulls, openApiNullable, true);
// Scenario 4: optional + nullable with openApiNullable → use JsonNullable
if (openApiNullable && !var.required && var.isNullable) {
var.vendorExtensions.put("x-is-jackson-optional-nullable", true);
}
+ resolveJsonIncludePolicy(cm, var);
+ }
+ for (CodegenProperty var : cm.requiredVars) {
+ // Honor a per-property x-jackson-json-setter-nulls override even on required properties
+ // (the automatic path emits nothing for required vars).
+ JsonAnnotationPolicyUtils.resolveJsonSetterNulls(cm, var, generateJsonSetterNullsAnnotations,
+ optionalNonNullPropertyJsonSetterNulls, openApiNullable, true);
+ resolveJsonIncludePolicy(cm, var);
}
}
@@ -1886,6 +1945,8 @@ 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);
+ extensions.add(VendorExtension.X_JACKSON_JSON_SETTER_NULLS);
return extensions;
}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/MysqlSchemaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/MysqlSchemaCodegen.java
index 8b8445f5fb84..624eee109469 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/MysqlSchemaCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/MysqlSchemaCodegen.java
@@ -31,7 +31,6 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import static org.openapitools.codegen.CodegenConstants.ENUM_VALUES;
import static org.openapitools.codegen.utils.EnumUtils.getEnumValues;
import static org.openapitools.codegen.utils.StringUtils.underscore;
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/NimClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/NimClientCodegen.java
index 366f1e04be50..a27c1edca6cc 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/NimClientCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/NimClientCodegen.java
@@ -23,6 +23,7 @@
import org.openapitools.codegen.meta.GeneratorMetadata;
import org.openapitools.codegen.meta.Stability;
import org.openapitools.codegen.meta.features.*;
+import org.openapitools.codegen.model.EnumVarMap;
import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationMap;
@@ -37,8 +38,6 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import static org.openapitools.codegen.CodegenConstants.ENUM_VALUE;
-import static org.openapitools.codegen.CodegenConstants.ENUM_VARS;
import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER;
import static org.openapitools.codegen.utils.EnumUtils.getEnumVars;
import static org.openapitools.codegen.utils.EnumUtils.hasEnumVars;
@@ -259,14 +258,14 @@ private void stripQuotesFromIntegerEnumValues(Map allowableValue
return;
}
- List> enumVars = getEnumVars(allowableValues);
- for (Map enumVar : enumVars) {
- Object value = enumVar.get(ENUM_VALUE);
+ List enumVars = getEnumVars(allowableValues);
+ for (EnumVarMap enumVar : enumVars) {
+ Object value = enumVar.getEnumValue();
if (value instanceof String) {
String strValue = (String) value;
// Remove surrounding quotes if present
if (strValue.startsWith("\"") && strValue.endsWith("\"")) {
- enumVar.put(ENUM_VALUE, strValue.substring(1, strValue.length() - 1));
+ enumVar.setEnumValue(strValue.substring(1, strValue.length() - 1));
}
}
}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OCamlClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OCamlClientCodegen.java
index a6500d64398a..c3b8cf8afc94 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OCamlClientCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OCamlClientCodegen.java
@@ -28,6 +28,7 @@
import org.apache.commons.lang3.StringUtils;
import org.openapitools.codegen.*;
import org.openapitools.codegen.meta.features.*;
+import org.openapitools.codegen.model.EnumVarMap;
import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationMap;
@@ -41,8 +42,7 @@
import java.util.stream.Collectors;
import static org.apache.commons.lang3.StringUtils.capitalize;
-import static org.openapitools.codegen.CodegenConstants.ENUM_NAME;
-import static org.openapitools.codegen.CodegenConstants.ENUM_VALUES;
+import static org.openapitools.codegen.model.EnumVarMap.ENUM_VALUES;
import static org.openapitools.codegen.utils.ModelUtils.hasAnyOf;
import static org.openapitools.codegen.utils.ModelUtils.hasOneOf;
import static org.openapitools.codegen.utils.StringUtils.escape;
@@ -812,13 +812,13 @@ private Map allowableValues(String valueString) {
return result;
}
- private List> buildEnumValues(String valueString) {
- List> result = new ArrayList<>();
+ private List buildEnumValues(String valueString) {
+ List result = new ArrayList<>();
for (String v : valueString.split(",")) {
- Map m = new HashMap<>();
+ EnumVarMap m = new EnumVarMap();
String value = v.isEmpty() ? "empty" : v;
- m.put(ENUM_NAME, value);
+ m.setEnumName(value);
m.put("camlEnumValueName", ocamlizeEnumValue(value));
result.add(m);
}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PostgresqlSchemaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PostgresqlSchemaCodegen.java
index 20f2d480ec58..4bbaa58f8911 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PostgresqlSchemaCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PostgresqlSchemaCodegen.java
@@ -33,7 +33,6 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import static org.openapitools.codegen.CodegenConstants.ENUM_VALUES;
import static org.openapitools.codegen.utils.EnumUtils.getEnumValues;
import static org.openapitools.codegen.utils.StringUtils.underscore;
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ProtobufSchemaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ProtobufSchemaCodegen.java
index 42cb762e732f..f108f515d648 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ProtobufSchemaCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ProtobufSchemaCodegen.java
@@ -32,6 +32,7 @@
import org.openapitools.codegen.meta.features.DocumentationFeature;
import org.openapitools.codegen.meta.features.SecurityFeature;
import org.openapitools.codegen.meta.features.WireFormatFeature;
+import org.openapitools.codegen.model.EnumVarMap;
import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationMap;
@@ -48,7 +49,7 @@
import com.google.common.base.CaseFormat;
-import static org.openapitools.codegen.CodegenConstants.*;
+import static org.openapitools.codegen.model.EnumVarMap.*;
import static org.openapitools.codegen.utils.EnumUtils.*;
import static org.openapitools.codegen.utils.ModelUtils.*;
import static org.openapitools.codegen.utils.StringUtils.*;
@@ -570,12 +571,12 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
*/
public void addEnumValuesPrefix(Map allowableValues, String prefix) {
if (hasEnumVars(allowableValues)) {
- List> enumVars = getEnumVars(allowableValues);
+ List enumVars = getEnumVars(allowableValues);
prefix = CaseFormat.LOWER_CAMEL.to(CaseFormat.UPPER_UNDERSCORE, prefix);
- for (Map value : enumVars) {
- String name = (String) value.get(ENUM_NAME);
- value.put(ENUM_NAME, useSimplifiedEnumNames ? name : prefix + "_" + name);
- value.put(ENUM_VALUE, useSimplifiedEnumNames ? name : "\"" + prefix + "_" + name + "\"");
+ for (EnumVarMap value : enumVars) {
+ String name = (String) value.getEnumName();
+ value.setEnumName(useSimplifiedEnumNames ? name : prefix + "_" + name);
+ value.setEnumValue(useSimplifiedEnumNames ? name : "\"" + prefix + "_" + name + "\"");
}
}
@@ -598,16 +599,12 @@ public void addUnspecifiedToAllowableValues(Map allowableValues)
if (startEnumsWithUnspecified) {
if (hasEnumVars(allowableValues)) {
- List> enumVars = getEnumVars(allowableValues);
+ List enumVars = getEnumVars(allowableValues);
boolean unspecifiedPresent = enumVars.stream()
- .anyMatch(e -> {
- return UNSPECIFIED.equals(e.get(ENUM_NAME));
- });
+ .anyMatch(e -> UNSPECIFIED.equals(e.getEnumName()));
if (!unspecifiedPresent) {
- HashMap unspecifiedEnum = new HashMap();
- unspecifiedEnum.put(ENUM_NAME, UNSPECIFIED);
- unspecifiedEnum.put(ENUM_IS_STRING, "false");
- unspecifiedEnum.put(ENUM_VALUE, "\"" + UNSPECIFIED + "\"");
+ EnumVarMap unspecifiedEnum = new EnumVarMap();
+ unspecifiedEnum.enumVar(UNSPECIFIED, "\"" + UNSPECIFIED + "\"", false);
enumVars.add(0, unspecifiedEnum);
}
}
@@ -628,9 +625,9 @@ public void addUnspecifiedToAllowableValues(Map allowableValues)
*
* @param enumVars list of enum vars
*/
- public void addEnumIndexes(List> enumVars) {
+ public void addEnumIndexes(List enumVars) {
int enumIndex = 0;
- for (Map enumVar : enumVars) {
+ for (EnumVarMap enumVar : enumVars) {
enumVar.put("protobuf-enum-index", enumIndex);
enumIndex++;
}
@@ -736,7 +733,7 @@ public ModelsMap postProcessModels(ModelsMap objs) {
addUnspecifiedToAllowableValues(allowableValues);
addEnumValuesPrefix(allowableValues, cm.getClassname());
if (hasEnumVars(allowableValues)) {
- List> enumVars = getEnumVars(allowableValues);
+ List enumVars = getEnumVars(allowableValues);
addEnumIndexes(enumVars);
}
}
@@ -792,7 +789,7 @@ public ModelsMap postProcessModels(ModelsMap objs) {
addEnumValuesPrefix(enumProperty.allowableValues, enumProperty.getEnumName());
if (hasEnumVars(enumProperty.allowableValues)) {
- List> enumVars = getEnumVars(enumProperty.allowableValues);
+ List enumVars = getEnumVars(enumProperty.allowableValues);
addEnumIndexes(enumVars);
}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java
index 0a15106d15ee..980baf7a6a65 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java
@@ -40,7 +40,6 @@
import java.util.*;
import java.util.regex.Pattern;
-import static org.openapitools.codegen.CodegenConstants.ENUM_VALUES;
import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER;
import static org.openapitools.codegen.utils.EnumUtils.getEnumValues;
import static org.openapitools.codegen.utils.StringUtils.camelize;
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java
index 47c841c88e3c..6a3481fad6f4 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java
@@ -28,6 +28,7 @@
import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationMap;
import org.openapitools.codegen.model.OperationsMap;
+import org.openapitools.codegen.model.EnumVarMap;
import org.openapitools.codegen.utils.ModelUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -37,9 +38,8 @@
import java.io.Writer;
import java.util.*;
-import static org.openapitools.codegen.CodegenConstants.*;
import static org.openapitools.codegen.utils.EnumUtils.getEnumValues;
-import static org.openapitools.codegen.utils.EnumUtils.getEnumVarsAsString;
+import static org.openapitools.codegen.utils.EnumUtils.getEnumVars;
import static org.openapitools.codegen.utils.StringUtils.camelize;
import static org.openapitools.codegen.utils.StringUtils.underscore;
@@ -784,8 +784,8 @@ private String constructExampleCode(CodegenModel codegenModel, HashMap> enumVars = getEnumVarsAsString(codegenModel.allowableValues);
- return moduleName + "::" + codegenModel.classname + "::" + enumVars.get(0).get(ENUM_NAME);
+ List enumVars = getEnumVars(codegenModel.allowableValues);
+ return moduleName + "::" + codegenModel.classname + "::" + enumVars.get(0).getEnumName();
} else if (codegenModel.oneOf != null && !codegenModel.oneOf.isEmpty()) {
String subModel = (String) codegenModel.oneOf.toArray()[0];
if (modelMaps.containsKey(subModel)) {
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustServerCodegen.java
index 161400d9b83b..29dd89d8c148 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustServerCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustServerCodegen.java
@@ -1383,9 +1383,84 @@ public Map postProcessSupportingFileData(Map bun
}
bundle.put("hasAuthScopes", hasAuthScopes);
+ addAuthSchemeTestsToBundle(authMethods, bundle);
+
return super.postProcessSupportingFileData(bundle);
}
+ /**
+ * Derive the facts the generated auth-scheme precedence tests need.
+ *
+ * Each generated block in `context.rs` returns early once it matches, so a block that fails to
+ * check which `AuthData` variant it received will claim credentials belonging to another scheme
+ * and make every later block unreachable (see issue #24095).
+ *
+ * Whether that is observable depends on block order, so the only thing the template cannot work
+ * out for itself is whether a block handling a given HTTP scheme precedes the apiKey block it
+ * could shadow. Everything else - which requests to send, which credentials to use - lives in
+ * the template.
+ */
+ private void addAuthSchemeTestsToBundle(List authMethods, Map bundle) {
+ boolean hasBasic = false;
+ boolean hasBearer = false;
+ boolean basicPrecedesHeaderApiKey = false;
+ boolean bearerPrecedesHeaderApiKey = false;
+ boolean basicPrecedesQueryApiKey = false;
+ boolean bearerPrecedesQueryApiKey = false;
+ String apiKeyHeaderName = null;
+ String apiKeyQueryName = null;
+
+ if (authMethods != null) {
+ for (CodegenSecurity authMethod : authMethods) {
+ boolean isBasic = Boolean.TRUE.equals(authMethod.isBasicBasic);
+ boolean isBearer = Boolean.TRUE.equals(authMethod.isBasicBearer)
+ || Boolean.TRUE.equals(authMethod.isOAuth);
+ boolean isApiKeyHeader = Boolean.TRUE.equals(authMethod.isApiKey)
+ && Boolean.TRUE.equals(authMethod.isKeyInHeader);
+ boolean isApiKeyQuery = Boolean.TRUE.equals(authMethod.isApiKey)
+ && Boolean.TRUE.equals(authMethod.isKeyInQuery);
+
+ hasBasic |= isBasic;
+ hasBearer |= isBearer;
+ // Only blocks generated before an apiKey block can shadow it, and the header and
+ // query blocks are shadowed independently: each matches a different part of the
+ // request, so a block that fails to match one may still precede and claim the other.
+ if (apiKeyHeaderName == null) {
+ basicPrecedesHeaderApiKey |= isBasic;
+ bearerPrecedesHeaderApiKey |= isBearer;
+ }
+ if (apiKeyQueryName == null) {
+ basicPrecedesQueryApiKey |= isBasic;
+ bearerPrecedesQueryApiKey |= isBearer;
+ }
+ if (isApiKeyHeader && apiKeyHeaderName == null) {
+ apiKeyHeaderName = authMethod.keyParamName.toLowerCase(Locale.ROOT);
+ }
+ if (isApiKeyQuery && apiKeyQueryName == null) {
+ apiKeyQueryName = authMethod.keyParamName;
+ }
+ }
+ }
+
+ bundle.put("authTestHasBasic", hasBasic);
+ bundle.put("authTestHasBearer", hasBearer);
+ bundle.put("authTestBasicPrecedesHeaderApiKey", basicPrecedesHeaderApiKey);
+ bundle.put("authTestBearerPrecedesHeaderApiKey", bearerPrecedesHeaderApiKey);
+ bundle.put("authTestBasicPrecedesQueryApiKey", basicPrecedesQueryApiKey);
+ bundle.put("authTestBearerPrecedesQueryApiKey", bearerPrecedesQueryApiKey);
+ bundle.put("authTestApiKeyHeader", apiKeyHeaderName);
+ bundle.put("authTestApiKeyQuery", apiKeyQueryName);
+ bundle.put("authTestHasApiKey", apiKeyHeaderName != null || apiKeyQueryName != null);
+
+ SupportingFile authTestFile =
+ new SupportingFile("tests-auth-scheme-precedence.mustache", "tests", "auth_scheme_precedence.rs");
+ if (hasBasic || hasBearer || apiKeyHeaderName != null || apiKeyQueryName != null) {
+ supportingFiles.add(authTestFile);
+ } else {
+ supportingFiles.remove(authTestFile);
+ }
+ }
+
/**
* Add a built path set map to the provided bundle
*
@@ -1652,10 +1727,10 @@ public ModelsMap postProcessModels(ModelsMap objs) {
additionalProperties.put("apiUsesIntegerEnums", true);
// Add numeric discriminant values for enum variants
- List> enumVars = getEnumVars(model.allowableValues);
+ List enumVars = getEnumVars(model.allowableValues);
if (enumVars != null) {
- for (Map enumVar : enumVars) {
+ for (EnumVarMap enumVar : enumVars) {
String value = (String) enumVar.get("value");
if (value != null) {
// Strip quotes to get raw numeric value
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaLagomServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaLagomServerCodegen.java
index c576549b78c5..da8396404481 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaLagomServerCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaLagomServerCodegen.java
@@ -31,7 +31,6 @@
import java.util.*;
-import static org.openapitools.codegen.CodegenConstants.ENUM_VALUES;
import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER;
import static org.openapitools.codegen.utils.EnumUtils.getEnumValues;
import static org.openapitools.codegen.utils.StringUtils.camelize;
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java
index 1e0285c66d0b..818b724d3535 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java
@@ -350,7 +350,10 @@ public Map postProcessAllModels(Map objs)
// Add discriminator mapping value if present
if (cModel.discriminator != null) {
- String discriminatorName = cModel.discriminator.getPropertyName();
+ // baseName, not propertyName: the latter is the escaped Scala
+ // identifier and never matches a property's baseName
+ String discriminatorName = cModel.discriminator.getPropertyBaseName();
+ childModel.getVendorExtensions().put("x-discriminator-property", discriminatorName);
// Find the mapping value for this child model
String discriminatorValue = null;
@@ -368,7 +371,7 @@ public Map postProcessAllModels(Map objs)
}
// Remove discriminator field from child
- // (circe-generic-extras adds it automatically)
+ // (the sealed trait's encoder writes it)
childModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName));
childModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
childModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
@@ -423,7 +426,7 @@ public Map postProcessAllModels(Map objs)
// Remove discriminator property from models that extend a oneOf parent
// (circe-generic-extras adds it automatically)
if (cModel.parent != null && cModel.parentModel != null && cModel.parentModel.discriminator != null) {
- String discriminatorName = cModel.parentModel.discriminator.getPropertyName();
+ String discriminatorName = cModel.parentModel.discriminator.getPropertyBaseName();
cModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName));
cModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
cModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
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 83e1fb20a863..7b5805f79c5f 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,8 +40,11 @@
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.JsonAnnotationPolicyUtils;
+import org.openapitools.codegen.utils.JsonIncludePolicy;
import org.openapitools.codegen.utils.ModelUtils;
import org.openapitools.codegen.utils.ProcessUtils;
+import org.openapitools.codegen.utils.TriStateBoolean;
import org.openapitools.codegen.utils.URLPathUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -159,6 +162,10 @@ public enum RequestMappingMode {
@Setter protected boolean apiFirst = false;
protected boolean useOptional = false;
@Setter protected boolean useSealed = false;
+ @Getter @Setter protected JsonIncludePolicy optionalNonNullPropertyJsonInclude = JsonIncludePolicy.NON_NULL;
+ @Getter @Setter protected JsonAnnotationPolicyUtils.JsonSetterNullsMode optionalNonNullPropertyJsonSetterNulls = null;
+ @Getter @Setter protected TriStateBoolean generateJsonIncludeAnnotations = TriStateBoolean.UNSET;
+ @Getter @Setter protected TriStateBoolean generateJsonSetterNullsAnnotations = TriStateBoolean.UNSET;
@Setter protected boolean virtualService = false;
@Setter protected boolean hateoas = false;
@Setter protected boolean returnSuccessCode = false;
@@ -281,6 +288,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,
+ JsonAnnotationPolicyUtils.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE_DESC);
+ for (JsonIncludePolicy policy : JsonIncludePolicy.OPTIONAL_NON_NULL_POLICIES) {
+ optionalNonNullPropertyJsonIncludeOpt.addEnum(policy.name(), policy.getDescription());
+ }
+ optionalNonNullPropertyJsonIncludeOpt.setDefault(optionalNonNullPropertyJsonInclude.name());
+ cliOptions.add(optionalNonNullPropertyJsonIncludeOpt);
+
+ CliOption optionalNonNullPropertyJsonSetterNullsOpt = CliOption.newString(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS,
+ JsonAnnotationPolicyUtils.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS_DESC);
+ optionalNonNullPropertyJsonSetterNullsOpt.addEnum(JsonAnnotationPolicyUtils.JsonSetterNullsMode.SKIP.name(),
+ "Emit @JsonSetter(nulls = Nulls.SKIP): silently ignore an explicit JSON null, keeping the field's default.");
+ optionalNonNullPropertyJsonSetterNullsOpt.addEnum(JsonAnnotationPolicyUtils.JsonSetterNullsMode.FAIL.name(),
+ "Emit @JsonSetter(nulls = Nulls.FAIL): reject an explicit JSON null.");
+ cliOptions.add(optionalNonNullPropertyJsonSetterNullsOpt);
+
+ cliOptions.add(CliOption.newBoolean(CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS,
+ JsonAnnotationPolicyUtils.GENERATE_JSON_INCLUDE_ANNOTATIONS_DESC, 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
@@ -555,6 +587,23 @@ 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,
+ value -> this.generateJsonIncludeAnnotations = TriStateBoolean.fromNullableBoolean(value));
+ convertPropertyToBooleanAndWriteBack(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS,
+ value -> this.generateJsonSetterNullsAnnotations = TriStateBoolean.fromNullableBoolean(value));
+ this.optionalNonNullPropertyJsonInclude = JsonAnnotationPolicyUtils.resolveOptionalNonNullPropertyJsonInclude(
+ additionalProperties, optionalNonNullPropertyJsonInclude);
+ additionalProperties.put(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE, optionalNonNullPropertyJsonInclude.name());
+ this.optionalNonNullPropertyJsonSetterNulls = JsonAnnotationPolicyUtils.resolveOptionalNonNullPropertyJsonSetterNulls(
+ additionalProperties, optionalNonNullPropertyJsonSetterNulls);
+ if (optionalNonNullPropertyJsonSetterNulls != null) {
+ additionalProperties.put(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, optionalNonNullPropertyJsonSetterNulls.name());
+ }
+ if (jackson) {
+ JsonAnnotationPolicyUtils.warnIfUnset(LOGGER, generateJsonIncludeAnnotations, generateJsonSetterNullsAnnotations);
+ JsonAnnotationPolicyUtils.warnIfJsonSetterNullsDefaultRisky(LOGGER, generateJsonSetterNullsAnnotations,
+ optionalNonNullPropertyJsonSetterNulls, openApiNullable, false);
+ }
if (DocumentationProvider.NONE.equals(getDocumentationProvider())) {
this.setUseSwaggerUI(false);
}
@@ -1208,24 +1257,22 @@ 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 + 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");
- }
+ // Optional + non-nullable: emit @JsonSetter(nulls = ...) so an explicit null in the payload does not
+ // silently overwrite the field's default. Also honors a per-property x-jackson-json-setter-nulls override
+ // and the optionalNonNullPropertyJsonSetterNulls option; see JsonAnnotationPolicyUtils#resolveJsonSetterNulls.
+ // spring's default path never emits Nulls.FAIL (failModeSupported=false), but the option/extension can.
+ JsonAnnotationPolicyUtils.resolveJsonSetterNulls(model, property, generateJsonSetterNullsAnnotations,
+ optionalNonNullPropertyJsonSetterNulls, openApiNullable, false);
+
+ // Resolve the @JsonInclude policy into the x-jackson-json-include-policy vendor extension; see
+ // JsonAnnotationPolicyUtils#resolveJsonIncludePolicy for the full precedence/matrix rules. For spring,
+ // required properties resolve to NON_NULL when non-nullable (contract protection) and ALWAYS when
+ // nullable (explicit null is valid and must be serialized).
+ JsonAnnotationPolicyUtils.resolveJsonIncludePolicy(model, property, generateJsonIncludeAnnotations,
+ optionalNonNullPropertyJsonInclude, JsonIncludePolicy.NON_NULL, JsonIncludePolicy.ALWAYS);
}
+
@Override
public CodegenModel fromModel(String name, Schema model) {
CodegenModel codegenModel = super.fromModel(name, model);
@@ -1559,6 +1606,8 @@ 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);
+ extensions.add(VendorExtension.X_JACKSON_JSON_SETTER_NULLS);
return extensions;
}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java
index f713a7e677d7..bc7473f9f861 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java
@@ -46,10 +46,9 @@
import java.util.stream.Collectors;
import static java.util.Objects.nonNull;
-import static org.openapitools.codegen.CodegenConstants.ENUM_NAME;
-import static org.openapitools.codegen.CodegenConstants.ENUM_VARS;
+import static org.openapitools.codegen.model.EnumVarMap.ENUM_VARS;
import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER;
-import static org.openapitools.codegen.utils.EnumUtils.getEnumVarsAsString;
+import static org.openapitools.codegen.utils.EnumUtils.getEnumVars;
import static org.openapitools.codegen.utils.OnceLogger.once;
import static org.openapitools.codegen.utils.StringUtils.*;
@@ -606,7 +605,7 @@ private void autoSetDefaultValueForProperty(ExtendedCodegenProperty var) {
var.defaultValue = "false";
} else {
if (var.allowableValues != null && var.allowableValues.get(ENUM_VARS) instanceof ArrayList && ((ArrayList>) var.allowableValues.get(ENUM_VARS)).get(0) instanceof HashMap) {
- var.defaultValue = var.dataTypeAlternate + "." + getEnumVarsAsString(var.allowableValues).get(0).get(ENUM_NAME);
+ var.defaultValue = var.dataTypeAlternate + "." + getEnumVars(var.allowableValues).get(0).getEnumName();
}
}
}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/model/EnumVarMap.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/model/EnumVarMap.java
new file mode 100644
index 000000000000..40726a647aec
--- /dev/null
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/model/EnumVarMap.java
@@ -0,0 +1,68 @@
+package org.openapitools.codegen.model;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+public class EnumVarMap extends HashMap {
+
+ // The raw enum values from the OpenAPI specification
+ public static final String ENUM_VALUES = "values";
+ // The map that stores all enum values and their metadata (name, value, enumDescription...)
+ public static final String ENUM_VARS = "enumVars";
+ // The name of the enum, for example NAME("value") in Java
+ public static final String ENUM_NAME = "name";
+ // The on-the-line value, i.e., the one present in the "values"
+ public static final String ENUM_VALUE = "value";
+ // If the enum is typed as a string
+ public static final String ENUM_IS_STRING = "isString";
+ // The description that should be attached to an entry in "enumVars"
+ public static final String ENUM_DESCRIPTION = "enumDescription";
+
+ public EnumVarMap() {
+
+ }
+
+ public EnumVarMap(EnumVarMap init) {
+ putAll(init);
+ }
+
+ public EnumVarMap(Map init) {
+ putAll(init);
+ }
+
+ public void enumVar(String enumName, String enumValue, boolean isString) {
+ put(ENUM_NAME, enumName);
+ put(ENUM_VALUE, enumValue);
+ put(ENUM_IS_STRING, isString);
+ }
+
+ public void setEnumName(String name) {
+ put(ENUM_NAME, name);
+ }
+
+ public Object getEnumName() {
+ return get(ENUM_NAME);
+ }
+
+ public void setEnumValue(String value) {
+ put(ENUM_VALUE, value);
+ }
+
+ public Object getEnumValue() {
+ return get(ENUM_VALUE);
+ }
+
+ public void isString(boolean isString) {
+ put(ENUM_IS_STRING, isString);
+ }
+
+ public boolean isString() {
+ Object value = get(ENUM_IS_STRING);
+ if (!(value instanceof Boolean)) {
+ throw new IllegalStateException(ENUM_IS_STRING + " is not a boolean: " + value);
+ }
+ return (Boolean) value;
+ }
+
+}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/EnumUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/EnumUtils.java
index 0cd4d5e8b536..314660c6a01f 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/EnumUtils.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/EnumUtils.java
@@ -2,12 +2,14 @@
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.media.Schema;
-import org.openapitools.codegen.CodegenConstants;
+import org.openapitools.codegen.model.EnumVarMap;
import java.util.*;
import java.util.stream.Collectors;
-import static org.openapitools.codegen.CodegenConstants.*;
+import static org.openapitools.codegen.CodegenConstants.X_ENUM_DEPRECATED;
+import static org.openapitools.codegen.CodegenConstants.X_ENUM_DESCRIPTIONS;
+import static org.openapitools.codegen.model.EnumVarMap.*;
public class EnumUtils {
@@ -22,7 +24,7 @@ public class EnumUtils {
/**
*
* @param allowableValues The allowableValues map
- * @return whether the allowableValues map contains {@value CodegenConstants#ENUM_VARS}
+ * @return whether the allowableValues map contains {@value EnumVarMap#ENUM_VARS}
*/
public static boolean hasEnumVars(Map allowableValues) {
return allowableValues != null && allowableValues.containsKey(ENUM_VARS);
@@ -31,25 +33,26 @@ public static boolean hasEnumVars(Map allowableValues) {
/**
*
* @param allowableValues The allowableValues map
- * @return whether the allowableValues map contains {@value CodegenConstants#ENUM_VALUES}
+ * @return whether the allowableValues map contains {@value EnumVarMap#ENUM_VALUES}
*/
public static boolean hasEnumValues(Map allowableValues) {
return allowableValues != null && allowableValues.containsKey(ENUM_VALUES);
}
/**
- * Get the {@value CodegenConstants#ENUM_VARS} from the allowableValues map.
+ * Get the {@value EnumVarMap#ENUM_VARS} from the allowableValues map.
+ *
* @param allowableValues The allowableValues map
* @return the list of enumVars
*/
- public static List> getEnumVars(Map allowableValues) {
+ public static List getEnumVars(Map allowableValues) {
@SuppressWarnings("unchecked")
- List> enumVars = (List>) allowableValues.get(ENUM_VARS);
+ List enumVars = (List) allowableValues.get(ENUM_VARS);
return enumVars;
}
/**
- * Get the {@value CodegenConstants#ENUM_VALUES} from the allowableValues map.
+ * Get the {@value EnumVarMap#ENUM_VALUES} from the allowableValues map.
* @param allowableValues The allowableValues map
* @return the list of enumValues
*/
@@ -60,18 +63,7 @@ public static List getEnumValues(Map allowableValues) {
}
/**
- * Get the {@value CodegenConstants#ENUM_VARS} from the allowableValues map.
- * @param allowableValues The allowableValues map
- * @return the list of enumVars
- */
- public static List> getEnumVarsAsString(Map allowableValues) {
- @SuppressWarnings("unchecked")
- List> enumVars = (List>) allowableValues.get(ENUM_VARS);
- return enumVars;
- }
-
- /**
- * Get the {@value CodegenConstants#ENUM_VALUES} from the allowableValues map.
+ * Get the {@value EnumVarMap#ENUM_VALUES} from the allowableValues map.
* @param allowableValues The allowableValues map
* @return the list of enumValues
*/
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/JsonAnnotationPolicyUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/JsonAnnotationPolicyUtils.java
new file mode 100644
index 000000000000..5818e65cdbf3
--- /dev/null
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/JsonAnnotationPolicyUtils.java
@@ -0,0 +1,420 @@
+/*
+ * 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 org.openapitools.codegen.CodegenConstants;
+import org.openapitools.codegen.CodegenModel;
+import org.openapitools.codegen.CodegenProperty;
+import org.openapitools.codegen.VendorExtension;
+import org.slf4j.Logger;
+
+import java.util.Map;
+
+/**
+ * Shared resolution/validation logic for the Jackson {@code @JsonInclude}/{@code @JsonSetter(nulls = ...)}
+ * config options and the {@code x-jackson-json-include-policy} vendor extension, used by both the
+ * {@code spring} and {@code kotlin-spring} generators (issue #24401).
+ */
+public final class JsonAnnotationPolicyUtils {
+
+ /** Shared {@code CliOption} description for {@code optionalNonNullPropertyJsonInclude}. */
+ public static final String OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE_DESC =
+ "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.";
+
+ /** Shared {@code CliOption} description for {@code optionalNonNullPropertyJsonSetterNulls}. */
+ public static final String OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS_DESC =
+ "The Jackson @JsonSetter(nulls = ...) mode emitted for optional, non-nullable model properties when "
+ + CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS + " is true. SKIP ignores an explicit "
+ + "JSON null (keeping the field's default), FAIL rejects it. When left unset the mode is derived "
+ + "from openApiNullable (true -> FAIL where supported, false -> SKIP), preserving 7.24.x behavior. "
+ + "A per-property override set via the `x-jackson-json-setter-nulls` vendor extension always wins.";
+
+ /** Shared {@code CliOption} description for {@code generateJsonIncludeAnnotations}. */
+ public static final String GENERATE_JSON_INCLUDE_ANNOTATIONS_DESC =
+ "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.";
+
+ /** Resolved {@code @JsonSetter(nulls = ...)} annotation to emit for a property, if any. */
+ public enum JsonSetterNullsMode {
+ /** No {@code @JsonSetter(nulls = ...)} annotation should be emitted. */
+ NONE,
+ /** Emit {@code @JsonSetter(nulls = Nulls.SKIP)}: silently ignore an explicit JSON null. */
+ SKIP,
+ /** Emit {@code @JsonSetter(nulls = Nulls.FAIL)}: reject an explicit JSON null. */
+ FAIL
+ }
+
+ private JsonAnnotationPolicyUtils() {
+ }
+
+ /**
+ * 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 name 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) {
+ JsonIncludePolicy parsed;
+ try {
+ parsed = JsonIncludePolicy.parse(rawPolicy);
+ } catch (IllegalArgumentException e) {
+ 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);
+ }
+ if (parsed == null || !parsed.isEmitted()) {
+ return null;
+ }
+ return parsed.name();
+ }
+
+ /**
+ * 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 name: 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 JsonIncludePolicy.NON_NULL.name();
+ }
+ JsonIncludePolicy parsed;
+ try {
+ parsed = JsonIncludePolicy.valueOf(policy.trim().toUpperCase(java.util.Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ parsed = null;
+ }
+ if (parsed == null || !parsed.isValidOptionalNonNullPolicy()) {
+ throw new IllegalArgumentException(optionName
+ + " must be one of " + JsonIncludePolicy.OPTIONAL_NON_NULL_POLICIES + " but was: " + policy);
+ }
+ return parsed.name();
+ }
+
+ /**
+ * Read back the {@code optionalNonNullPropertyJsonInclude} config option from {@code additionalProperties},
+ * validating/normalizing it via {@link #normalizeJsonIncludePolicy}. Returns {@code current} unchanged when
+ * the option was not present in {@code additionalProperties} (i.e. the generator's existing/default value is
+ * kept). Callers are responsible for writing the resolved value back via their own
+ * {@code writePropertyBack}/{@code additionalProperties.put} convention.
+ *
+ * @param additionalProperties the generator's additional properties map
+ * @param current the generator's current {@code optionalNonNullPropertyJsonInclude} value
+ * @return the resolved {@link JsonIncludePolicy} to assign to the generator's field
+ */
+ public static JsonIncludePolicy resolveOptionalNonNullPropertyJsonInclude(Map additionalProperties, JsonIncludePolicy current) {
+ if (!additionalProperties.containsKey(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE)) {
+ return current;
+ }
+ return JsonIncludePolicy.valueOf(normalizeJsonIncludePolicy(
+ additionalProperties.get(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE).toString(),
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_INCLUDE));
+ }
+
+ /**
+ * Log the shared "option left unset, defaulting to false" warnings for
+ * {@code generateJsonIncludeAnnotations}/{@code generateJsonSetterNullsAnnotations} on the caller's own
+ * {@link Logger}. No-op for each option that is not {@link TriStateBoolean#UNSET}.
+ *
+ * @param logger the calling generator's own {@code LOGGER}
+ * @param generateJsonIncludeAnnotations the resolved {@code generateJsonIncludeAnnotations} state
+ * @param generateJsonSetterNullsAnnotations the resolved {@code generateJsonSetterNullsAnnotations} state
+ */
+ public static void warnIfUnset(Logger logger, TriStateBoolean generateJsonIncludeAnnotations, TriStateBoolean generateJsonSetterNullsAnnotations) {
+ if (generateJsonIncludeAnnotations.isUnset()) {
+ 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.isUnset()) {
+ 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);
+ }
+ }
+
+ /**
+ * Resolve the {@code @JsonInclude} policy into the single universal
+ * {@code x-jackson-json-include-policy} vendor extension the template emits, mutating {@code model.imports}
+ * and {@code property.vendorExtensions} in place. Precedence:
+ *
+ * A value set directly on the property (manual override in the spec) always wins.
+ * Otherwise, when {@code generateJsonIncludeAnnotations=true}, apply the automatic matrix:
+ *
+ * required & non-nullable → {@code requiredNonNullablePolicy}
+ * required & nullable → {@code requiredNullablePolicy}
+ * optional & non-nullable → {@code optionalNonNullPropertyJsonInclude}
+ * (default {@code NON_NULL}, {@code NONE} = omit)
+ * optional & nullable → none (JsonNullable module already governs inclusion)
+ *
+ *
+ *
+ *
+ * The two "required" policies are supplied by the caller since the spring and kotlin-spring generators
+ * apply slightly different matrices there: kotlin-spring's non-nullable Kotlin types can never hold null,
+ * so it always uses {@code ALWAYS} for required properties, whereas spring uses {@code NON_NULL} for
+ * required-non-nullable (contract protection) and {@code ALWAYS} for required-nullable.
+ *
+ * @param model the model owning {@code property}, whose imports may be extended
+ * @param property the property to resolve the policy for
+ * @param generateJsonIncludeAnnotations the resolved {@code generateJsonIncludeAnnotations} state
+ * @param optionalNonNullPropertyJsonInclude the resolved {@code optionalNonNullPropertyJsonInclude} policy
+ * @param requiredNonNullablePolicy the policy to apply to required, non-nullable properties
+ * @param requiredNullablePolicy the policy to apply to required, nullable properties
+ */
+ public static void resolveJsonIncludePolicy(CodegenModel model, CodegenProperty property,
+ TriStateBoolean generateJsonIncludeAnnotations, JsonIncludePolicy optionalNonNullPropertyJsonInclude,
+ JsonIncludePolicy requiredNonNullablePolicy, JsonIncludePolicy requiredNullablePolicy) {
+ if (property.vendorExtensions.containsKey(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName())) {
+ String manualPolicy = 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 (!generateJsonIncludeAnnotations.isTrue()) {
+ return;
+ }
+ JsonIncludePolicy policy = null;
+ if (property.required) {
+ policy = property.isNullable ? requiredNullablePolicy : requiredNonNullablePolicy;
+ } else if (!property.isNullable) {
+ policy = optionalNonNullPropertyJsonInclude;
+ }
+ if (policy != null && policy.isEmitted()) {
+ property.vendorExtensions.put(VendorExtension.X_JACKSON_JSON_INCLUDE_POLICY.getName(), policy.name());
+ model.imports.add("JsonInclude");
+ }
+ }
+
+ /**
+ * Resolve which {@code @JsonSetter(nulls = ...)} annotation, if any, should be emitted for an optional,
+ * non-nullable property.
+ *
+ * @param generateJsonSetterNullsAnnotations the resolved {@code generateJsonSetterNullsAnnotations} state
+ * @param required whether the property is required
+ * @param nullable whether the property is nullable
+ * @param openApiNullable whether the generator's {@code openApiNullable} option is enabled
+ * @param failModeSupported whether this generator supports {@link JsonSetterNullsMode#FAIL} (kotlin-spring
+ * does; spring currently only ever emits {@link JsonSetterNullsMode#SKIP})
+ * @return {@link JsonSetterNullsMode#NONE} unless the property is optional and non-nullable and
+ * {@code generateJsonSetterNullsAnnotations} is explicitly enabled; otherwise {@link JsonSetterNullsMode#FAIL}
+ * when {@code openApiNullable && failModeSupported}, {@link JsonSetterNullsMode#SKIP} when
+ * {@code !openApiNullable}, or {@link JsonSetterNullsMode#NONE} when {@code openApiNullable && !failModeSupported}.
+ */
+ public static JsonSetterNullsMode resolveJsonSetterNullsMode(TriStateBoolean generateJsonSetterNullsAnnotations,
+ boolean required, boolean nullable, boolean openApiNullable, boolean failModeSupported) {
+ return resolveJsonSetterNullsMode(generateJsonSetterNullsAnnotations, required, nullable, openApiNullable,
+ failModeSupported, null);
+ }
+
+ /**
+ * Resolve which {@code @JsonSetter(nulls = ...)} annotation, if any, should be emitted for an optional,
+ * non-nullable property, honoring the {@code optionalNonNullPropertyJsonSetterNulls} global override.
+ *
+ * @param generateJsonSetterNullsAnnotations the resolved {@code generateJsonSetterNullsAnnotations} state
+ * @param required whether the property is required
+ * @param nullable whether the property is nullable
+ * @param openApiNullable whether the generator's {@code openApiNullable} option is enabled
+ * @param failModeSupported whether this generator's default path emits {@link JsonSetterNullsMode#FAIL}
+ * (kotlin-spring does; spring's default path never does)
+ * @param optionalNonNullOverride the resolved {@code optionalNonNullPropertyJsonSetterNulls} option
+ * ({@code null} when unset); when set it wins over the {@code openApiNullable}
+ * default and is emitted regardless of {@code failModeSupported}
+ * @return the mode to emit for an optional non-nullable property, or {@link JsonSetterNullsMode#NONE}
+ */
+ public static JsonSetterNullsMode resolveJsonSetterNullsMode(TriStateBoolean generateJsonSetterNullsAnnotations,
+ boolean required, boolean nullable, boolean openApiNullable, boolean failModeSupported,
+ JsonSetterNullsMode optionalNonNullOverride) {
+ if (!generateJsonSetterNullsAnnotations.isTrue() || required || nullable) {
+ return JsonSetterNullsMode.NONE;
+ }
+ if (optionalNonNullOverride != null) {
+ return optionalNonNullOverride;
+ }
+ if (openApiNullable) {
+ return failModeSupported ? JsonSetterNullsMode.FAIL : JsonSetterNullsMode.NONE;
+ }
+ return JsonSetterNullsMode.SKIP;
+ }
+
+ /**
+ * Validate and normalize the {@code optionalNonNullPropertyJsonSetterNulls} config option value.
+ *
+ * @param value the raw config option value
+ * @param optionName the config option name, used in the error message
+ * @return the normalized {@link JsonSetterNullsMode} ({@code SKIP} or {@code FAIL})
+ * @throws IllegalArgumentException when the value is not {@code SKIP} or {@code FAIL}
+ */
+ public static JsonSetterNullsMode normalizeJsonSetterNulls(String value, String optionName) {
+ JsonSetterNullsMode parsed = null;
+ if (value != null) {
+ try {
+ parsed = JsonSetterNullsMode.valueOf(value.trim().toUpperCase(java.util.Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ parsed = null;
+ }
+ }
+ if (parsed != JsonSetterNullsMode.SKIP && parsed != JsonSetterNullsMode.FAIL) {
+ throw new IllegalArgumentException(optionName + " must be one of [SKIP, FAIL] but was: " + value);
+ }
+ return parsed;
+ }
+
+ /**
+ * Read back the {@code optionalNonNullPropertyJsonSetterNulls} config option from
+ * {@code additionalProperties}, validating/normalizing it via {@link #normalizeJsonSetterNulls}. Returns
+ * {@code current} unchanged when the option was not present (i.e. the generator's existing/default value,
+ * typically {@code null} = unset, is kept).
+ *
+ * @param additionalProperties the generator's additional properties map
+ * @param current the generator's current {@code optionalNonNullPropertyJsonSetterNulls} value
+ * @return the resolved {@link JsonSetterNullsMode} ({@code null} when left unset)
+ */
+ public static JsonSetterNullsMode resolveOptionalNonNullPropertyJsonSetterNulls(Map additionalProperties, JsonSetterNullsMode current) {
+ if (!additionalProperties.containsKey(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS)) {
+ return current;
+ }
+ return normalizeJsonSetterNulls(
+ java.util.Objects.toString(additionalProperties.get(CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS), null),
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS);
+ }
+
+ /**
+ * Validate and normalize a manual per-property {@code x-jackson-json-setter-nulls} override.
+ *
+ * @param rawValue the raw vendor extension value set directly on the property in the spec
+ * @param extensionName the vendor extension key, used in the error message
+ * @return the normalized {@link JsonSetterNullsMode}: {@code SKIP}/{@code FAIL} to emit that annotation, or
+ * {@link JsonSetterNullsMode#NONE} when the override means "emit no annotation" ({@code NONE}/blank)
+ * @throws IllegalArgumentException when the override is not {@code SKIP}, {@code FAIL}, or {@code NONE}
+ */
+ public static JsonSetterNullsMode resolveManualJsonSetterNulls(Object rawValue, String extensionName) {
+ if (rawValue == null) {
+ return JsonSetterNullsMode.NONE;
+ }
+ String trimmed = rawValue.toString().trim();
+ if (trimmed.isEmpty()) {
+ return JsonSetterNullsMode.NONE;
+ }
+ try {
+ return JsonSetterNullsMode.valueOf(trimmed.toUpperCase(java.util.Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException(extensionName
+ + " must be one of SKIP, FAIL, or NONE (to emit no annotation), but was: " + rawValue);
+ }
+ }
+
+ /**
+ * Resolve the {@code @JsonSetter(nulls = ...)} annotation for a property into the
+ * {@code x-has-json-setter-nulls-skip}/{@code -fail} vendor extension the templates emit, mutating
+ * {@code property.vendorExtensions} and {@code model.imports} in place. Precedence (mirrors
+ * {@link #resolveJsonIncludePolicy}):
+ *
+ * A per-property {@code x-jackson-json-setter-nulls} override in the spec always wins and is honored
+ * unconditionally (regardless of {@code generateJsonSetterNullsAnnotations}, required/nullable,
+ * {@code openApiNullable}, or {@code failModeSupported}).
+ * Otherwise the automatic optional-non-nullable mode from
+ * {@link #resolveJsonSetterNullsMode(TriStateBoolean, boolean, boolean, boolean, boolean, JsonSetterNullsMode)}.
+ *
+ *
+ * @param model the model owning {@code property}, whose imports may be extended
+ * @param property the property to resolve the mode for
+ * @param generateJsonSetterNullsAnnotations the resolved {@code generateJsonSetterNullsAnnotations} state
+ * @param optionalNonNullOverride the resolved {@code optionalNonNullPropertyJsonSetterNulls} option
+ * ({@code null} when unset)
+ * @param openApiNullable whether the generator's {@code openApiNullable} option is enabled
+ * @param failModeSupported whether this generator's default path emits {@code FAIL}
+ */
+ public static void resolveJsonSetterNulls(CodegenModel model, CodegenProperty property,
+ TriStateBoolean generateJsonSetterNullsAnnotations, JsonSetterNullsMode optionalNonNullOverride,
+ boolean openApiNullable, boolean failModeSupported) {
+ JsonSetterNullsMode mode;
+ if (property.vendorExtensions.containsKey(VendorExtension.X_JACKSON_JSON_SETTER_NULLS.getName())) {
+ mode = resolveManualJsonSetterNulls(
+ property.vendorExtensions.get(VendorExtension.X_JACKSON_JSON_SETTER_NULLS.getName()),
+ VendorExtension.X_JACKSON_JSON_SETTER_NULLS.getName());
+ } else {
+ mode = resolveJsonSetterNullsMode(generateJsonSetterNullsAnnotations, property.required,
+ property.isNullable, openApiNullable, failModeSupported, optionalNonNullOverride);
+ }
+ applyJsonSetterNullsMode(model, property, mode);
+ }
+
+ /**
+ * Apply a resolved {@link JsonSetterNullsMode} to a property: sets the
+ * {@code x-has-json-setter-nulls-skip}/{@code -fail} vendor extension and adds the required imports.
+ * No-op for {@link JsonSetterNullsMode#NONE}/{@code null}.
+ */
+ public static void applyJsonSetterNullsMode(CodegenModel model, CodegenProperty property, JsonSetterNullsMode mode) {
+ if (mode == null || mode == JsonSetterNullsMode.NONE) {
+ return;
+ }
+ property.vendorExtensions.put(mode == JsonSetterNullsMode.FAIL
+ ? "x-has-json-setter-nulls-fail" : "x-has-json-setter-nulls-skip", true);
+ model.imports.add("JsonSetter");
+ model.imports.add("Nulls");
+ }
+
+ /**
+ * Log a one-time warning when the generator falls back to the (technically risky) "no {@code @JsonSetter}"
+ * default for optional non-nullable properties: {@code generateJsonSetterNullsAnnotations=true},
+ * no {@code optionalNonNullPropertyJsonSetterNulls} override, {@code openApiNullable=true}, and the
+ * generator's default path does not emit {@code FAIL} ({@code failModeSupported=false}, i.e. spring/java).
+ * In that case an explicit JSON null falls back to the global mapper default ({@code Nulls.SET}) and
+ * overwrites field defaults. kotlin-spring passes {@code failModeSupported=true} and stays silent.
+ */
+ public static void warnIfJsonSetterNullsDefaultRisky(Logger logger, TriStateBoolean generateJsonSetterNullsAnnotations,
+ JsonSetterNullsMode optionalNonNullOverride, boolean openApiNullable, boolean failModeSupported) {
+ if (generateJsonSetterNullsAnnotations.isTrue() && optionalNonNullOverride == null && openApiNullable && !failModeSupported) {
+ logger.warn("'{}=true' with 'openApiNullable=true' emits no @JsonSetter(nulls = ...) on optional "
+ + "non-nullable properties, so deserialization defers to the global ObjectMapper default "
+ + "(Nulls.SET): an explicit JSON null overwrites the field's default value. Set '{}=SKIP' to "
+ + "ignore incoming nulls (preserve defaults) or '{}=FAIL' to reject them, or use the "
+ + "`x-jackson-json-setter-nulls` vendor extension per property, to control this explicitly.",
+ CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS,
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS,
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS);
+ }
+ }
+}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/JsonIncludePolicy.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/JsonIncludePolicy.java
new file mode 100644
index 000000000000..6c10f3f92b10
--- /dev/null
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/JsonIncludePolicy.java
@@ -0,0 +1,80 @@
+package org.openapitools.codegen.utils;
+
+import lombok.Getter;
+
+import java.util.EnumSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Mirrors {@code com.fasterxml.jackson.annotation.JsonInclude.Include} (the set of policies a
+ * generated {@code @JsonInclude} annotation can be given), plus a {@link #NONE} sentinel meaning
+ * "emit no {@code @JsonInclude} annotation at all", used by the {@code spring} and
+ * {@code kotlin-spring} generators (issue #24401) for:
+ *
+ *
+ * the {@code optionalNonNullPropertyJsonInclude} config option (restricted to
+ * {@link #NON_NULL}, {@link #NON_EMPTY}, {@link #NON_DEFAULT}, {@link #NONE} — see
+ * {@link #OPTIONAL_NON_NULL_POLICIES})
+ * the resolved {@code x-jackson-json-include-policy} vendor extension value, whether set
+ * manually on a property in the spec or computed by the automatic required/nullable matrix
+ * (any value is allowed there)
+ *
+ */
+@Getter
+public enum JsonIncludePolicy {
+ ALWAYS,
+ NON_NULL("Omit the property when its value is null (default, spec-safe for non-nullable fields)."),
+ NON_ABSENT,
+ NON_EMPTY("Omit the property when its value is null or considered empty."),
+ NON_DEFAULT("Omit the property when its value equals the default."),
+ USE_DEFAULTS,
+ CUSTOM,
+ /** Sentinel meaning "emit no {@code @JsonInclude} annotation". */
+ NONE("Emit no @JsonInclude annotation; defer to the global ObjectMapper.");
+
+ /**
+ * CLI-facing description, only populated for the values in {@link #OPTIONAL_NON_NULL_POLICIES}
+ * (the only ones ever offered as a {@code CliOption} enum choice); {@code null} otherwise.
+ */
+ private final String description;
+
+ JsonIncludePolicy() {
+ this(null);
+ }
+
+ JsonIncludePolicy(String description) {
+ this.description = description;
+ }
+
+ /** The subset of policies accepted for the {@code optionalNonNullPropertyJsonInclude} config option. */
+ public static final Set OPTIONAL_NON_NULL_POLICIES =
+ EnumSet.of(NON_NULL, NON_EMPTY, NON_DEFAULT, NONE);
+
+ /** Whether this policy should result in an emitted {@code @JsonInclude} annotation. */
+ public boolean isEmitted() {
+ return this != NONE;
+ }
+
+ /** Whether this policy is a valid value for the {@code optionalNonNullPropertyJsonInclude} config option. */
+ public boolean isValidOptionalNonNullPolicy() {
+ return OPTIONAL_NON_NULL_POLICIES.contains(this);
+ }
+
+ /**
+ * Parse a raw string (trimmed, case-insensitive) into a {@link JsonIncludePolicy}, or return
+ * {@code null} if blank/whitespace-only (callers treat that the same as {@link #NONE}).
+ *
+ * @throws IllegalArgumentException if non-blank but not a valid {@link JsonIncludePolicy} name
+ */
+ public static JsonIncludePolicy parse(Object rawPolicy) {
+ if (rawPolicy == null) {
+ return null;
+ }
+ String trimmed = rawPolicy.toString().trim();
+ if (trimmed.isEmpty()) {
+ return null;
+ }
+ return JsonIncludePolicy.valueOf(trimmed.toUpperCase(Locale.ROOT));
+ }
+}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java
index 17ce0a391b46..0be9f0d7f758 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java
@@ -2847,6 +2847,19 @@ public static boolean containsEnums(OpenAPI openAPI) {
return schemaMap.values().stream().anyMatch(ModelUtils::isEnumSchema);
}
+ /**
+ * Whether all branches in the oneOf contains a {@code const}. Returns false for OAS 3.0 since that does not support
+ * {@code const}.
+ * @param schema The Schema
+ * @return true if all {@code oneOf} branches contains a {@code const}.
+ */
+ public static boolean isOneOfOfConsts(Schema> schema) {
+ if (hasOneOf(schema) && !schema.getSpecVersion().equals(SpecVersion.V30)) {
+ return schema.getOneOf().stream().allMatch(oneOf -> oneOf.getConst() != null);
+ }
+ return false;
+ }
+
@FunctionalInterface
private interface OpenAPISchemaVisitor {
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/TriStateBoolean.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/TriStateBoolean.java
new file mode 100644
index 000000000000..c8a07567c0dd
--- /dev/null
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/TriStateBoolean.java
@@ -0,0 +1,44 @@
+package org.openapitools.codegen.utils;
+
+/**
+ * A tri-state toggle for boolean-like config options that need to distinguish "the user never set
+ * this" from an explicit {@code true}/{@code false}, e.g. so a weak default can be applied along
+ * with a one-time warning when the option was left unset, while an explicit {@code false} silently
+ * keeps the same default behavior.
+ *
+ * This replaces the previous convention of using a boxed {@code Boolean} field where
+ * {@code null} meant "unset", which requires a code comment at every declaration site to be
+ * understandable.
+ */
+public enum TriStateBoolean {
+ /** The option was never explicitly set by the user (neither {@code true} nor {@code false}). */
+ UNSET,
+ /** The option was explicitly set to {@code false}. */
+ FALSE,
+ /** The option was explicitly set to {@code true}. */
+ TRUE;
+
+ /** Whether this state represents an explicit, affirmative opt-in ({@link #TRUE}). */
+ public boolean isTrue() {
+ return this == TRUE;
+ }
+
+ /** Whether the option was never explicitly set ({@link #UNSET}). */
+ public boolean isUnset() {
+ return this == UNSET;
+ }
+
+ /**
+ * Convert a nullable {@link Boolean} (the CLI/config-option convention where {@code null} means
+ * "not present in additionalProperties") into a {@link TriStateBoolean}.
+ *
+ * @param value {@code null} for unset, {@code Boolean.TRUE}/{@code Boolean.FALSE} for an explicit value
+ * @return {@link #UNSET}, {@link #TRUE}, or {@link #FALSE}
+ */
+ public static TriStateBoolean fromNullableBoolean(Boolean value) {
+ if (value == null) {
+ return UNSET;
+ }
+ return value ? TRUE : FALSE;
+ }
+}
diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache
index 964db40884ac..47b1fd30dae0 100644
--- a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache
+++ b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache
@@ -62,14 +62,15 @@ 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}}
+ {{#vendorExtensions.x-has-json-setter-nulls-fail}}
+ @JsonSetter(nulls = Nulls.FAIL)
+ {{/vendorExtensions.x-has-json-setter-nulls-fail}}
{{/jackson}}
{{#deprecated}}
@Deprecated
@@ -252,11 +253,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/_common/git_push.sh.mustache b/modules/openapi-generator/src/main/resources/_common/git_push.sh.mustache
index 0e3776ae6dd4..7782eddb9a30 100755
--- a/modules/openapi-generator/src/main/resources/_common/git_push.sh.mustache
+++ b/modules/openapi-generator/src/main/resources/_common/git_push.sh.mustache
@@ -8,24 +8,24 @@ git_repo_id=$2
release_note=$3
git_host=$4
-if [ "$git_host" = "" ]; then
+if [ -z "${git_host}" ]; then
git_host="{{{gitHost}}}"
- echo "[INFO] No command line input provided. Set \$git_host to $git_host"
+ echo "[INFO] No command line input provided. Set \${git_host} to ${git_host}"
fi
-if [ "$git_user_id" = "" ]; then
+if [ -z "${git_user_id}" ]; then
git_user_id="{{{gitUserId}}}"
- echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
+ echo "[INFO] No command line input provided. Set \${git_user_id} to ${git_user_id}"
fi
-if [ "$git_repo_id" = "" ]; then
+if [ -z "${git_repo_id}" ]; then
git_repo_id="{{{gitRepoId}}}"
- echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
+ echo "[INFO] No command line input provided. Set \${git_repo_id} to ${git_repo_id}"
fi
-if [ "$release_note" = "" ]; then
+if [ -z "${release_note}" ]; then
release_note="{{{releaseNote}}}"
- echo "[INFO] No command line input provided. Set \$release_note to $release_note"
+ echo "[INFO] No command line input provided. Set \${release_note} to ${release_note}"
fi
# Initialize the local directory as a Git repository
@@ -35,19 +35,16 @@ git init
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
-git commit -m "$release_note"
+git commit -m "${release_note}"
# Sets the new remote
-git_remote=$(git remote)
-if [ "$git_remote" = "" ]; then # git remote not defined
-
- if [ "$GIT_TOKEN" = "" ]; then
- echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
- git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
+if [ -z "$(git remote)" ]; then # git remote not defined
+ if [ -z "${GIT_TOKEN:-}" ]; then
+ echo "[INFO] \${GIT_TOKEN} (environment variable) is not set. Using the git credential in your environment."
+ git remote add origin "https://${git_host}/${git_user_id}/${git_repo_id}.git"
else
- git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
+ git remote add origin "https://${git_user_id}:${GIT_TOKEN}@${git_host}/${git_user_id}/${git_repo_id}.git"
fi
-
fi
git pull origin master
diff --git a/modules/openapi-generator/src/main/resources/go-server/go.mod.mustache b/modules/openapi-generator/src/main/resources/go-server/go.mod.mustache
index f1ee8194c831..ad342668fb8f 100644
--- a/modules/openapi-generator/src/main/resources/go-server/go.mod.mustache
+++ b/modules/openapi-generator/src/main/resources/go-server/go.mod.mustache
@@ -1,6 +1,6 @@
module {{gitHost}}/{{gitUserId}}/{{gitRepoId}}
-go 1.18
+go 1.25
{{#hasPathParams}}
{{#routers}}
@@ -11,7 +11,7 @@ require github.com/gorilla/handlers v1.5.1
{{/featureCORS}}
{{/mux}}
{{#chi}}
-require github.com/go-chi/chi/v5 v5.2.2
+require github.com/go-chi/chi/v5 v5.2.4
{{#featureCORS}}
require github.com/go-chi/cors v1.2.1
{{/featureCORS}}
diff --git a/modules/openapi-generator/src/main/resources/go/model_oneof.mustache b/modules/openapi-generator/src/main/resources/go/model_oneof.mustache
index 5021dd1df0f2..daaf9360b19b 100644
--- a/modules/openapi-generator/src/main/resources/go/model_oneof.mustache
+++ b/modules/openapi-generator/src/main/resources/go/model_oneof.mustache
@@ -80,16 +80,11 @@ func (dst *{{classname}}) UnmarshalJSON(data []byte) error {
} else if match == 1 {
return nil // exactly one match
} else { // no match
- {{#oneOf}}
- if err != nil {
- return fmt.Errorf("data failed to match schemas in oneOf({{classname}}): %v", err)
- } else {
- return fmt.Errorf("data failed to match schemas in oneOf({{classname}})")
- }
- {{/oneOf}}
- {{^oneOf}}
- return fmt.Errorf("data failed to match schemas in oneOf({{classname}})")
- {{/oneOf}}
+ if err != nil {
+ return fmt.Errorf("data failed to match schemas in oneOf({{classname}}): %v", err)
+ }
+
+ return fmt.Errorf("data failed to match schemas in oneOf({{classname}})")
}
{{/discriminator}}
{{/useOneOfDiscriminatorLookup}}
@@ -124,16 +119,11 @@ func (dst *{{classname}}) UnmarshalJSON(data []byte) error {
} else if match == 1 {
return nil // exactly one match
} else { // no match
- {{#oneOf}}
- if err != nil {
- return fmt.Errorf("data failed to match schemas in oneOf({{classname}}): %v", err)
- } else {
- return fmt.Errorf("data failed to match schemas in oneOf({{classname}})")
- }
- {{/oneOf}}
- {{^oneOf}}
- return fmt.Errorf("data failed to match schemas in oneOf({{classname}})")
- {{/oneOf}}
+ if err != nil {
+ return fmt.Errorf("data failed to match schemas in oneOf({{classname}}): %v", err)
+ }
+
+ return fmt.Errorf("data failed to match schemas in oneOf({{classname}})")
}
{{/useOneOfDiscriminatorLookup}}
}
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..0fe6660a8159 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,9 @@
{{#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}}{{#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}}}")
@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/main/resources/rust-server/tests-auth-scheme-precedence.mustache b/modules/openapi-generator/src/main/resources/rust-server/tests-auth-scheme-precedence.mustache
new file mode 100644
index 000000000000..c3f666173c94
--- /dev/null
+++ b/modules/openapi-generator/src/main/resources/rust-server/tests-auth-scheme-precedence.mustache
@@ -0,0 +1,151 @@
+//! Runtime checks for auth-scheme precedence in the generated `AddContext` middleware.
+//!
+//! This file is generated. `swagger::auth::from_headers` returns an *untyped* `AuthData`,
+//! matching an `Authorization` header that carries either `Basic` or `Bearer` credentials.
+//! Every generated auth block returns early once it matches, so a block that does not check
+//! which variant it received will claim credentials belonging to a different scheme and
+//! prevent every later block - including API-key blocks - from ever running.
+//!
+//! The expectations below are derived from the security schemes this API declares, in the
+//! order their blocks are generated.
+
+#![cfg(feature = "server")]
+
+use std::sync::{Arc, Mutex};
+
+use hyper::service::Service;
+use hyper::{Request, Response};
+use swagger::auth::AuthData;
+use swagger::{EmptyContext, Has};
+use {{{externCrateName}}}::context::AddContext;
+
+/// Innermost service: records the `Option` that `AddContext` pushed onto the context.
+#[derive(Clone, Default)]
+struct CaptureAuthData(Arc>>);
+
+impl Service<(Request, C)> for CaptureAuthData
+where
+ C: Has>,
+{
+ type Response = Response;
+ type Error = std::convert::Infallible;
+ type Future = std::future::Ready>;
+
+ fn call(&self, (_request, context): (Request, C)) -> Self::Future {
+ let auth_data: &Option = context.get();
+ *self.0.lock().expect("lock poisoned") = auth_data.clone();
+ std::future::ready(Ok(Response::new(String::new())))
+ }
+}
+
+/// Drives a request through `AddContext` and returns the `AuthData` it resolved.
+fn resolve_auth_data(uri: &str, headers: &[(&str, &str)]) -> Option {
+ let capture = CaptureAuthData::default();
+ let service = AddContext::<_, EmptyContext>::new(capture.clone());
+
+ let mut builder = Request::get(uri);
+ for (name, value) in headers {
+ builder = builder.header(*name, *value);
+ }
+ let request = builder.body(()).expect("request should build");
+
+ futures::executor::block_on(service.call(request)).expect("service call should succeed");
+
+ let resolved = capture.0.lock().expect("lock poisoned").clone();
+ resolved
+}
+
+/// `dXNlcjpwYXNzd29yZA==` is `user:password`.
+const BASIC_HEADER: &str = "Basic dXNlcjpwYXNzd29yZA==";
+const BEARER_HEADER: &str = "Bearer some-token";
+{{#authTestHasApiKey}}
+const API_KEY: &str = "test-api-key";
+{{/authTestHasApiKey}}
+
+#[test]
+fn no_credentials_resolve_to_no_auth_data() {
+ assert_eq!(resolve_auth_data("/", &[]), None);
+}
+
+#[test]
+{{#authTestHasBasic}}
+fn basic_credentials_resolve_to_the_declared_basic_scheme() {
+{{/authTestHasBasic}}
+{{^authTestHasBasic}}
+fn basic_credentials_resolve_to_none_when_no_basic_scheme_is_declared() {
+{{/authTestHasBasic}}
+ assert_eq!(
+ resolve_auth_data("/", &[("authorization", BASIC_HEADER)]),
+ {{#authTestHasBasic}}Some(AuthData::Basic("user".to_owned(), "password".to_owned())){{/authTestHasBasic}}{{^authTestHasBasic}}None{{/authTestHasBasic}},
+ );
+}
+
+#[test]
+{{#authTestHasBearer}}
+fn bearer_credentials_resolve_to_the_declared_bearer_scheme() {
+{{/authTestHasBearer}}
+{{^authTestHasBearer}}
+fn bearer_credentials_resolve_to_none_when_no_bearer_scheme_is_declared() {
+{{/authTestHasBearer}}
+ assert_eq!(
+ resolve_auth_data("/", &[("authorization", BEARER_HEADER)]),
+ {{#authTestHasBearer}}Some(AuthData::Bearer("some-token".to_owned())){{/authTestHasBearer}}{{^authTestHasBearer}}None{{/authTestHasBearer}},
+ );
+}
+{{#authTestApiKeyHeader}}
+
+#[test]
+fn header_api_key_resolves_when_it_is_the_only_credential() {
+ assert_eq!(
+ resolve_auth_data("/", &[("{{{.}}}", API_KEY)]),
+ Some(AuthData::ApiKey(API_KEY.to_owned())),
+ );
+}
+
+/// An `Authorization` header must not shadow the apiKey block unless a block that actually
+/// handles that scheme is generated before it.
+#[test]
+fn header_api_key_is_reachable_alongside_bearer_credentials() {
+ assert_eq!(
+ resolve_auth_data("/", &[("authorization", BEARER_HEADER), ("{{{.}}}", API_KEY)]),
+ {{#authTestBearerPrecedesHeaderApiKey}}Some(AuthData::Bearer("some-token".to_owned())){{/authTestBearerPrecedesHeaderApiKey}}{{^authTestBearerPrecedesHeaderApiKey}}Some(AuthData::ApiKey(API_KEY.to_owned())){{/authTestBearerPrecedesHeaderApiKey}},
+ );
+}
+
+#[test]
+fn header_api_key_is_reachable_alongside_basic_credentials() {
+ assert_eq!(
+ resolve_auth_data("/", &[("authorization", BASIC_HEADER), ("{{{.}}}", API_KEY)]),
+ {{#authTestBasicPrecedesHeaderApiKey}}Some(AuthData::Basic("user".to_owned(), "password".to_owned())){{/authTestBasicPrecedesHeaderApiKey}}{{^authTestBasicPrecedesHeaderApiKey}}Some(AuthData::ApiKey(API_KEY.to_owned())){{/authTestBasicPrecedesHeaderApiKey}},
+ );
+}
+{{/authTestApiKeyHeader}}
+{{#authTestApiKeyQuery}}
+
+#[test]
+fn query_api_key_resolves_when_it_is_the_only_credential() {
+ assert_eq!(
+ resolve_auth_data("/?{{{.}}}=test-api-key", &[]),
+ Some(AuthData::ApiKey(API_KEY.to_owned())),
+ );
+}
+
+/// The query apiKey block is shadowed independently of the header one: an `Authorization`
+/// header must not claim a request whose credentials are in the query string unless a block
+/// that actually handles that scheme is generated before the query block.
+#[test]
+fn query_api_key_is_reachable_alongside_bearer_credentials() {
+ assert_eq!(
+ resolve_auth_data("/?{{{.}}}=test-api-key", &[("authorization", BEARER_HEADER)]),
+ {{#authTestBearerPrecedesQueryApiKey}}Some(AuthData::Bearer("some-token".to_owned())){{/authTestBearerPrecedesQueryApiKey}}{{^authTestBearerPrecedesQueryApiKey}}Some(AuthData::ApiKey(API_KEY.to_owned())){{/authTestBearerPrecedesQueryApiKey}},
+ );
+}
+
+#[test]
+fn query_api_key_is_reachable_alongside_basic_credentials() {
+ assert_eq!(
+ resolve_auth_data("/?{{{.}}}=test-api-key", &[("authorization", BASIC_HEADER)]),
+ {{#authTestBasicPrecedesQueryApiKey}}Some(AuthData::Basic("user".to_owned(), "password".to_owned())){{/authTestBasicPrecedesQueryApiKey}}{{^authTestBasicPrecedesQueryApiKey}}Some(AuthData::ApiKey(API_KEY.to_owned())){{/authTestBasicPrecedesQueryApiKey}},
+ );
+}
+{{/authTestApiKeyQuery}}
diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/api.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/api.mustache
index 1afd073d825e..dc8e59028dfd 100644
--- a/modules/openapi-generator/src/main/resources/scala-sttp4/api.mustache
+++ b/modules/openapi-generator/src/main/resources/scala-sttp4/api.mustache
@@ -20,7 +20,7 @@ class {{classname}}(baseUrl: String) {
{{>javadoc}}
{{/javadocRenderer}}
- def {{operationId}}({{>methodParameters}}): Request[{{#separateErrorChannel}}Either[ResponseException[String], {{>operationReturnType}}]{{/separateErrorChannel}}{{^separateErrorChannel}}{{>operationReturnType}}{{/separateErrorChannel}}] =
+ def {{operationId}}({{>methodParameters}}): sttp.client4.Request[{{#separateErrorChannel}}Either[ResponseException[String], {{>operationReturnType}}]{{/separateErrorChannel}}{{^separateErrorChannel}}{{>operationReturnType}}{{/separateErrorChannel}}] =
basicRequest
.method(Method.{{httpMethod.toUpperCase}}, uri"$baseUrl{{{path}}}{{#queryParams.0}}?{{/queryParams.0}}{{#queryParams}}{{baseName}}=${ {{paramName}} }{{^-last}}&{{/-last}}{{/queryParams}}{{#authMethods}}{{#isApiKey}}{{#isKeyInQuery}}{{#queryParams.0}}&{{/queryParams.0}}{{^queryParams.0}}?{{/queryParams.0}}{{keyParamName}}=${apiKeyQuery}{{/isKeyInQuery}}{{/isApiKey}}{{/authMethods}}")
.contentType({{#consumes.0}}"{{{mediaType}}}"{{/consumes.0}}{{^consumes}}"application/json"{{/consumes}}){{#headerParams}}
diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache
index 7c710728f583..67d131fe9d35 100644
--- a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache
+++ b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache
@@ -49,10 +49,41 @@ case class {{classname}}(
object {{classname}} {
import io.circe._
import io.circe.syntax._
- import io.circe.generic.semiauto._
- implicit val encoder: Encoder[{{classname}}] = deriveEncoder[{{classname}}].mapJson(_.dropNullValues)
- implicit val decoder: Decoder[{{classname}}] = deriveDecoder
+{{^allVars}}
+ implicit val encoder: Encoder[{{classname}}] = Encoder.instance(_ => Json.obj())
+ implicit val decoder: Decoder[{{classname}}] = Decoder.const({{classname}}())
+{{/allVars}}
+{{#allVars}}
+{{#-first}}
+ implicit val encoder: Encoder[{{classname}}] = Encoder.instance { t =>
+ Json.fromFields(
+ Seq(
+{{/-first}}
+ {{#required}}Some("{{baseName}}" -> t.{{{name}}}.asJson){{/required}}{{^required}}t.{{{name}}}.map(v => "{{baseName}}" -> v.asJson){{/required}}{{^-last}},{{/-last}}
+{{#-last}}
+ ).flatten
+ )
+ }
+{{/-last}}
+{{/allVars}}
+{{#allVars}}
+{{#-first}}
+ implicit val decoder: Decoder[{{classname}}] = Decoder.instance { c =>
+ for {
+{{/-first}}
+ {{{name}}} <- c.downField("{{baseName}}").as[{{^required}}Option[{{/required}}{{dataType}}{{^required}}]{{/required}}]
+{{#-last}}
+ } yield {{classname}}(
+{{/-last}}
+{{/allVars}}
+{{#allVars}}
+ {{{name}}} = {{{name}}}{{^-last}},{{/-last}}
+{{#-last}}
+ )
+ }
+{{/-last}}
+{{/allVars}}
}
{{/circe}}
@@ -176,30 +207,39 @@ object {{classname}} {
{{#circe}}
{{^vendorExtensions.x-hasWrappedOneOfMembers}}
{{^vendorExtensions.x-use-discr}}
- // oneOf without discriminator - using semiauto derivation
+ // oneOf without discriminator - try each member in turn
import io.circe.{Encoder, Decoder}
- import io.circe.generic.semiauto._
+ import io.circe.syntax._
- implicit val encoder: Encoder[{{classname}}] = deriveEncoder[{{classname}}].mapJson(_.dropNullValues)
- implicit val decoder: Decoder[{{classname}}] = deriveDecoder
+ implicit val encoder: Encoder[{{classname}}] = Encoder.instance {
+{{#vendorExtensions.x-oneOfMembers}}
+ case obj: {{classname}} => obj.asJson
+{{/vendorExtensions.x-oneOfMembers}}
+ }
+ implicit val decoder: Decoder[{{classname}}] = List[Decoder[{{classname}}]](
+{{#vendorExtensions.x-oneOfMembers}}
+ Decoder[{{classname}}].map(x => x: {{vendorExtensions.x-oneOfParent}}),
+{{/vendorExtensions.x-oneOfMembers}}
+ ).reduceLeft(_ or _)
{{/vendorExtensions.x-use-discr}}
{{#vendorExtensions.x-use-discr}}
- // oneOf with discriminator - using semiauto derivation with Configuration
- import io.circe.{Encoder, Decoder}
- import io.circe.generic.extras._
- import io.circe.generic.extras.semiauto._
+ // oneOf with discriminator
+ import io.circe.{Encoder, Decoder, DecodingFailure}
+ import io.circe.syntax._
- private implicit val config: Configuration = Configuration.default.withDiscriminator("{{discriminator.propertyBaseName}}")
- .copy(
- transformConstructorNames = {
+ implicit val encoder: Encoder[{{classname}}] = Encoder.instance {
{{#vendorExtensions.x-oneOfMembers}}
- case "{{classname}}" => "{{vendorExtensions.x-discriminator-value}}"
+ case obj: {{classname}} => obj.asJson.mapObject(("{{vendorExtensions.x-discriminator-property}}" -> "{{vendorExtensions.x-discriminator-value}}".asJson) +: _)
{{/vendorExtensions.x-oneOfMembers}}
- case other => sys.error(s"Invalid {{classname}} discriminant: ${other}")
- }
- )
- implicit val encoder: Encoder[{{classname}}] = deriveConfiguredEncoder[{{classname}}].mapJson(_.dropNullValues)
- implicit val decoder: Decoder[{{classname}}] = deriveConfiguredDecoder
+ }
+ implicit val decoder: Decoder[{{classname}}] = Decoder.instance { c =>
+ c.downField("{{discriminator.propertyBaseName}}").as[String].flatMap {
+{{#vendorExtensions.x-oneOfMembers}}
+ case "{{vendorExtensions.x-discriminator-value}}" => c.as[{{classname}}].map(x => x: {{vendorExtensions.x-oneOfParent}})
+{{/vendorExtensions.x-oneOfMembers}}
+ case other => Left(DecodingFailure(s"Unknown {{discriminator.propertyBaseName}}: $other", c.history))
+ }
+ }
{{/vendorExtensions.x-use-discr}}
{{/vendorExtensions.x-hasWrappedOneOfMembers}}
{{#vendorExtensions.x-hasWrappedOneOfMembers}}
@@ -239,7 +279,7 @@ object {{classname}} {
implicit val decoder: Decoder[{{classname}}] = Decoder.instance { c =>
c.get[String]("{{discriminator.propertyBaseName}}").flatMap {
{{#vendorExtensions.x-oneOfMembers}}
- case "{{vendorExtensions.x-discriminator-value}}" => c.as[{{classname}}]({{classname}}.decoder).map(x => x: {{parentClassname}})
+ case "{{vendorExtensions.x-discriminator-value}}" => c.as[{{classname}}]({{classname}}.decoder).map(x => x: {{vendorExtensions.x-oneOfParent}})
{{/vendorExtensions.x-oneOfMembers}}
{{#vendorExtensions.x-wrappedOneOfMembers}}
case "{{discriminatorValue}}" => c.as[{{classname}}]({{classname}}.decoder).map({{wrapperClassname}}.apply)
@@ -324,10 +364,41 @@ case class {{classname}}(
object {{classname}} {
import io.circe._
import io.circe.syntax._
- import io.circe.generic.semiauto._
- implicit val encoder: Encoder[{{classname}}] = deriveEncoder[{{classname}}].mapJson(_.dropNullValues)
- implicit val decoder: Decoder[{{classname}}] = deriveDecoder
+{{^vars}}
+ implicit val encoder: Encoder[{{classname}}] = Encoder.instance(_ => Json.obj())
+ implicit val decoder: Decoder[{{classname}}] = Decoder.const({{classname}}())
+{{/vars}}
+{{#vars}}
+{{#-first}}
+ implicit val encoder: Encoder[{{classname}}] = Encoder.instance { t =>
+ Json.fromFields(
+ Seq(
+{{/-first}}
+ {{#required}}Some("{{baseName}}" -> t.{{{name}}}.asJson){{/required}}{{^required}}t.{{{name}}}.map(v => "{{baseName}}" -> v.asJson){{/required}}{{^-last}},{{/-last}}
+{{#-last}}
+ ).flatten
+ )
+ }
+{{/-last}}
+{{/vars}}
+{{#vars}}
+{{#-first}}
+ implicit val decoder: Decoder[{{classname}}] = Decoder.instance { c =>
+ for {
+{{/-first}}
+ {{{name}}} <- c.downField("{{baseName}}").as[{{^required}}Option[{{/required}}{{^isEnum}}{{dataType}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}Enums.{{datatypeWithEnum}}{{/isArray}}{{#isArray}}Seq[{{classname}}Enums.{{datatypeWithEnum}}]{{/isArray}}{{/isEnum}}{{^required}}]{{/required}}]
+{{#-last}}
+ } yield {{classname}}(
+{{/-last}}
+{{/vars}}
+{{#vars}}
+ {{{name}}} = {{{name}}}{{^-last}},{{/-last}}
+{{#-last}}
+ )
+ }
+{{/-last}}
+{{/vars}}
}
{{/circe}}
{{#hasEnums}}
diff --git a/modules/openapi-generator/src/main/resources/typescript-angular/modelEnum.mustache b/modules/openapi-generator/src/main/resources/typescript-angular/modelEnum.mustache
index 8fdf09b4697d..74fdc47c183d 100644
--- a/modules/openapi-generator/src/main/resources/typescript-angular/modelEnum.mustache
+++ b/modules/openapi-generator/src/main/resources/typescript-angular/modelEnum.mustache
@@ -22,7 +22,7 @@ export const {{classname}} = {
* {{.}}
*/
{{/enumDescription}}
- {{name}}: {{{value}}}{{^-last}},{{/-last}}
+ {{name}}: {{{value}}},
{{/enumVars}}
{{/allowableValues}}
} as const;
diff --git a/modules/openapi-generator/src/main/resources/typescript-angular/modelGenericEnums.mustache b/modules/openapi-generator/src/main/resources/typescript-angular/modelGenericEnums.mustache
index 8e636697b67f..4da64fee7731 100644
--- a/modules/openapi-generator/src/main/resources/typescript-angular/modelGenericEnums.mustache
+++ b/modules/openapi-generator/src/main/resources/typescript-angular/modelGenericEnums.mustache
@@ -18,7 +18,7 @@ export enum {{classname}}{{enumName}} {
export const {{enumName}} = {
{{#allowableValues}}
{{#enumVars}}
- {{name}}: {{{value}}}{{^-last}},{{/-last}}
+ {{name}}: {{{value}}},
{{/enumVars}}
{{/allowableValues}}
} as const;
diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/apisOperationEnum.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/apisOperationEnum.mustache
index e1641254e521..b8c09fe1479d 100644
--- a/modules/openapi-generator/src/main/resources/typescript-fetch/apisOperationEnum.mustache
+++ b/modules/openapi-generator/src/main/resources/typescript-fetch/apisOperationEnum.mustache
@@ -8,7 +8,7 @@
export enum {{operationIdCamelCase}}{{enumName}} {
{{#allowableValues}}
{{#enumVars}}
- {{{name}}} = {{{value}}}{{^-last}},{{/-last}}
+ {{{name}}} = {{{value}}},
{{/enumVars}}
{{/allowableValues}}
}
@@ -20,7 +20,7 @@ export enum {{operationIdCamelCase}}{{enumName}} {
export const {{operationIdCamelCase}}{{enumName}} = {
{{#allowableValues}}
{{#enumVars}}
- {{{name}}}: {{{value}}}{{^-last}},{{/-last}}
+ {{{name}}}: {{{value}}},
{{/enumVars}}
{{/allowableValues}}
} as const;
diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/modelEnumInterfaces.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/modelEnumInterfaces.mustache
index 8d44b927affc..e6395c6fb972 100644
--- a/modules/openapi-generator/src/main/resources/typescript-fetch/modelEnumInterfaces.mustache
+++ b/modules/openapi-generator/src/main/resources/typescript-fetch/modelEnumInterfaces.mustache
@@ -29,7 +29,7 @@ export const {{classname}} = {
* {{enumDescription}}
*/
{{/enumDescription}}
- {{{name}}}: {{{value}}}{{^-last}},{{/-last}}
+ {{{name}}}: {{{value}}},
{{/enumVars}}
{{/allowableValues}}
} as const;
diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/modelGenericInterfaces.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/modelGenericInterfaces.mustache
index 7d6aceec7a5e..e1ce9eac0d2c 100644
--- a/modules/openapi-generator/src/main/resources/typescript-fetch/modelGenericInterfaces.mustache
+++ b/modules/openapi-generator/src/main/resources/typescript-fetch/modelGenericInterfaces.mustache
@@ -45,7 +45,7 @@ export enum {{classname}}{{enumName}} {
export const {{classname}}{{enumName}} = {
{{#allowableValues}}
{{#enumVars}}
- {{{name}}}: {{{value}}}{{^-last}},{{/-last}}
+ {{{name}}}: {{{value}}},
{{/enumVars}}
{{/allowableValues}}
} as const;
diff --git a/modules/openapi-generator/src/main/resources/typescript-nestjs-server/modelEnum.mustache b/modules/openapi-generator/src/main/resources/typescript-nestjs-server/modelEnum.mustache
index 8fdf09b4697d..74fdc47c183d 100644
--- a/modules/openapi-generator/src/main/resources/typescript-nestjs-server/modelEnum.mustache
+++ b/modules/openapi-generator/src/main/resources/typescript-nestjs-server/modelEnum.mustache
@@ -22,7 +22,7 @@ export const {{classname}} = {
* {{.}}
*/
{{/enumDescription}}
- {{name}}: {{{value}}}{{^-last}},{{/-last}}
+ {{name}}: {{{value}}},
{{/enumVars}}
{{/allowableValues}}
} as const;
diff --git a/modules/openapi-generator/src/main/resources/typescript-nestjs-server/modelGenericEnums.mustache b/modules/openapi-generator/src/main/resources/typescript-nestjs-server/modelGenericEnums.mustache
index 7c3eb9ec2645..044c154a6481 100644
--- a/modules/openapi-generator/src/main/resources/typescript-nestjs-server/modelGenericEnums.mustache
+++ b/modules/openapi-generator/src/main/resources/typescript-nestjs-server/modelGenericEnums.mustache
@@ -18,7 +18,7 @@ export namespace {{classname}} {
export const {{enumName}} = {
{{#allowableValues}}
{{#enumVars}}
- {{name}}: {{{value}}}{{^-last}},{{/-last}}
+ {{name}}: {{{value}}},
{{/enumVars}}
{{/allowableValues}}
} as const;
diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java
index 1b75a51b7100..9bb321618003 100644
--- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java
+++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java
@@ -43,6 +43,8 @@
public class OpenAPINormalizerTest {
+ private static final String SIMPLIFY_ONE_OF_ANY_OF = "SIMPLIFY_ONEOF_ANYOF";
+ private static final String SIMPLIFY_ONEOF_ANYOF_ENUM = "SIMPLIFY_ONEOF_ANYOF_ENUM";
private static final String REF_AS_PARENT_IN_ALLOF = "REF_AS_PARENT_IN_ALLOF";
private static final String X_PARENT = "x-parent";
private static final String X_INTERNAL = "x-internal";
@@ -210,7 +212,7 @@ public void testSimplifyOneOfAnyOfEnum() throws Exception {
// Test with rule enabled (default)
Map options = new HashMap<>();
- options.put("SIMPLIFY_ONEOF_ANYOF_ENUM", "true");
+ options.put(SIMPLIFY_ONEOF_ANYOF_ENUM, "true");
OpenAPINormalizer normalizer = new OpenAPINormalizer(openAPI, options);
normalizer.normalize();
@@ -248,7 +250,7 @@ public void testSimplifyOneOfAnyOfEnum() throws Exception {
// Test with rule disabled
OpenAPI openAPI2 = TestUtils.parseSpec("src/test/resources/3_0/simplifyOneOfWithEnums_test.yaml");
Map options2 = new HashMap<>();
- options2.put("SIMPLIFY_ONEOF_ANYOF_ENUM", "false");
+ options2.put(SIMPLIFY_ONEOF_ANYOF_ENUM, "false");
OpenAPINormalizer normalizer2 = new OpenAPINormalizer(openAPI2, options2);
normalizer2.normalize();
@@ -312,7 +314,7 @@ public void testOpenAPINormalizerSimplifyOneOfAnyOf() {
assertEquals(schema19.getAnyOf().size(), 1);
Map options = new HashMap<>();
- options.put("SIMPLIFY_ONEOF_ANYOF", "true");
+ options.put(SIMPLIFY_ONE_OF_ANY_OF, "true");
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, options);
openAPINormalizer.normalize();
@@ -370,7 +372,7 @@ public void testOpenAPINormalizerSimplifyOneOfWithSingleRef() {
assertEquals(((Schema) oneOfWithSingleRef.getProperties().get("number")).getOneOf().size(), 1);
Map options = new HashMap<>();
- options.put("SIMPLIFY_ONEOF_ANYOF", "true");
+ options.put(SIMPLIFY_ONE_OF_ANY_OF, "true");
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, options);
openAPINormalizer.normalize();
@@ -511,7 +513,7 @@ public void testOpenAPINormalizerConvertEnumNullToNullable() {
assertNull(schema.getNullable());
Map options = new HashMap<>();
- options.put("SIMPLIFY_ONEOF_ANYOF", "true");
+ options.put(SIMPLIFY_ONE_OF_ANY_OF, "true");
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, options);
openAPINormalizer.normalize();
@@ -1539,7 +1541,7 @@ public void testOpenAPINormalizerSimplifyOneOfAnyOf31Spec() {
// start the normalization
Map options = new HashMap<>();
- options.put("SIMPLIFY_ONEOF_ANYOF", "true");
+ options.put(SIMPLIFY_ONE_OF_ANY_OF, "true");
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, options);
openAPINormalizer.normalize();
@@ -1613,6 +1615,40 @@ public void testOpenAPINormalizerSimplifyOneOfAnyOf31Spec() {
assertEquals(((Schema) schema24.getProperties().get("anyof_nullable_number")).getTypes().size(), 1);
}
+ @Test
+ public void testOneOfWithStringsWithDifferentPatternsAreCollapsedWithSimplifyOneOfAnyOf() {
+ OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/simplifyOneOfAnyOf_test.yaml");
+
+ Schema stringPatternsWithOneOf = openAPI.getComponents().getSchemas().get("StringPatternsWithOneOf");
+ assertEquals(stringPatternsWithOneOf.getOneOf().size(), 2);
+
+ // start the normalization
+ Map options = new HashMap<>();
+ options.put(SIMPLIFY_ONE_OF_ANY_OF, "true");
+ OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, options);
+ openAPINormalizer.normalize();
+
+ Schema normalizedStringPatternsWithOneOf = openAPI.getComponents().getSchemas().get("StringPatternsWithOneOf");
+ assertNull(normalizedStringPatternsWithOneOf.getOneOf());
+ }
+
+ @Test
+ public void testOneOfWithConstsIsUntouchedBySimplifyOneOfAnyOf() {
+ OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/simplifyOneOfAnyOf_test.yaml");
+
+ Schema integerWithOneOfConsts = openAPI.getComponents().getSchemas().get("TypeIntegerWithOneOf");
+ assertEquals(integerWithOneOfConsts.getOneOf().size(), 3);
+
+ // start the normalization
+ Map options = new HashMap<>();
+ options.put(SIMPLIFY_ONEOF_ANYOF_ENUM, "false");
+ OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, options);
+ openAPINormalizer.normalize();
+
+ Schema normalizedIntegerWithOneOfConsts = openAPI.getComponents().getSchemas().get("TypeIntegerWithOneOf");
+ assertEquals(normalizedIntegerWithOneOfConsts.getOneOf().size(), 3);
+ }
+
@Test
public void testOpenAPINormalizerSimplifyOneOfWithSingleRef31Spec() {
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/simplifyOneOfAnyOf_test.yaml");
@@ -1621,7 +1657,7 @@ public void testOpenAPINormalizerSimplifyOneOfWithSingleRef31Spec() {
assertEquals(((Schema) oneOfWithSingleRef.getProperties().get("number")).getOneOf().size(), 1);
Map options = new HashMap<>();
- options.put("SIMPLIFY_ONEOF_ANYOF", "true");
+ options.put(SIMPLIFY_ONE_OF_ANY_OF, "true");
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, options);
openAPINormalizer.normalize();
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 c77a0ac16cf2..516336cb7904 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
@@ -8455,57 +8455,433 @@ 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 #24491: {@code optionalNonNullPropertyJsonSetterNulls} decouples the SKIP/FAIL choice from
+ * {@code openApiNullable} on spring. With {@code openApiNullable=true} the default emits no
+ * {@code @JsonSetter}; SKIP and FAIL are now both reachable (FAIL via the new template branch).
+ */
+ @Test
+ void jsonSetterNulls_option_decouplesFromOpenApiNullable() throws IOException {
+ // openApiNullable=true + SKIP (previously unreachable): tolerate an explicit null
+ Map skip = 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",
+ CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "SKIP"));
+ assertFileContains(skip.get("OptionalNonNullable.java").toPath(), "@JsonSetter(nulls = Nulls.SKIP)");
+ assertFileNotContains(skip.get("OptionalNonNullable.java").toPath(), "@JsonSetter(nulls = Nulls.FAIL)");
+ // @JsonInclude is still emitted independently
+ assertFileContains(skip.get("OptionalNonNullable.java").toPath(), "@JsonInclude(JsonInclude.Include.NON_NULL)");
+
+ // openApiNullable=true + FAIL: reject an explicit null (uses the new -fail template branch)
+ Map fail = 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_SETTER_NULLS_ANNOTATIONS, "true",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "FAIL"));
+ assertFileContains(fail.get("OptionalNonNullable.java").toPath(), "@JsonSetter(nulls = Nulls.FAIL)");
+
+ // openApiNullable=false + FAIL: overrides the SKIP default
+ Map failNoNullable = 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",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "FAIL"));
+ assertFileContains(failNoNullable.get("OptionalNonNullable.java").toPath(), "@JsonSetter(nulls = Nulls.FAIL)");
+ assertFileNotContains(failNoNullable.get("OptionalNonNullable.java").toPath(), "@JsonSetter(nulls = Nulls.SKIP)");
+ }
+
+ /**
+ * Issue #24491: an invalid {@code optionalNonNullPropertyJsonSetterNulls} value fails fast with an
+ * actionable error naming the option and allowed values.
+ */
+ @Test
+ void jsonSetterNulls_option_invalidValueFailsFast() {
+ try {
+ generateFromContract(
+ "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml",
+ SPRING_BOOT,
+ Map.of(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "BOGUS"));
+ org.assertj.core.api.Assertions.fail("expected an IllegalArgumentException for an invalid optionalNonNullPropertyJsonSetterNulls value");
+ } catch (Exception e) {
+ Throwable root = e;
+ while (root.getCause() != null) {
+ root = root.getCause();
+ }
+ assertThat(root).isInstanceOf(IllegalArgumentException.class);
+ assertThat(root.getMessage()).contains("optionalNonNullPropertyJsonSetterNulls").contains("[SKIP, FAIL]");
+ }
+ }
+
+ /**
+ * Issue #24491: the per-property {@code x-jackson-json-setter-nulls} vendor extension overrides the
+ * mode precisely per field (SKIP/FAIL/NONE), winning over the option and unconditionally applying
+ * even to a required property.
+ */
+ @Test
+ void jsonSetterNulls_perPropertyExtension() throws IOException {
+ Map files = generateFromContract(
+ "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml",
+ SPRING_BOOT,
+ // openApiNullable=true + option SKIP: the extension must still win per property
+ Map.of(CodegenConstants.OPENAPI_NULLABLE, "true",
+ CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "SKIP"));
+
+ assertFileContains(files.get("SetterNullsManualSkip.java").toPath(), "@JsonSetter(nulls = Nulls.SKIP)");
+ assertFileContains(files.get("SetterNullsManualFail.java").toPath(), "@JsonSetter(nulls = Nulls.FAIL)");
+ assertFileNotContains(files.get("SetterNullsManualNone.java").toPath(), "@JsonSetter(");
+ // forced FAIL on a required property is honored unconditionally
+ assertFileContains(files.get("SetterNullsForcedOnRequired.java").toPath(), "@JsonSetter(nulls = Nulls.FAIL)");
+ }
+
+ /**
+ * Issue #24491: the per-property {@code x-jackson-json-setter-nulls} override is honored even when
+ * {@code generateJsonSetterNullsAnnotations} is not enabled (mirrors the JsonInclude manual override).
+ */
+ @Test
+ void jsonSetterNulls_perPropertyExtension_honoredWhenFlagDisabled() throws IOException {
+ Map files = generateFromContract(
+ "src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml",
+ SPRING_BOOT,
+ Map.of());
+ assertFileContains(files.get("SetterNullsManualFail.java").toPath(), "@JsonSetter(nulls = Nulls.FAIL)");
+ assertFileContains(files.get("SetterNullsManualFail.java").toPath(),
+ "com.fasterxml.jackson.annotation.JsonSetter", "com.fasterxml.jackson.annotation.Nulls");
+ }
+
+ /**
+ * 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 a38495cef8b8..edad12367b27 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
@@ -6724,7 +6724,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);
@@ -6754,7 +6755,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);
@@ -6782,7 +6785,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);
@@ -6802,9 +6806,163 @@ public void requiredNullable_scenario3_optionalNonNullable_withDefault() throws
}
/**
- * Scenario 4 (openApiNullable=false, default): required=false, nullable=true
- * Without openApiNullable, falls back to nullable type with null default (same as before this feature).
+ * Issue #24491: the {@code optionalNonNullPropertyJsonSetterNulls=SKIP} option decouples the
+ * SKIP/FAIL choice from {@code openApiNullable}. With {@code openApiNullable=true} the default would
+ * be FAIL, but the option forces the (previously unreachable) SKIP so an explicit null is tolerated.
+ */
+ @Test(description = "optionalNonNullPropertyJsonSetterNulls=SKIP overrides the openApiNullable=true FAIL default")
+ public void jsonSetterNulls_option_skipOverridesOpenApiNullableTrue() throws IOException {
+ Map files = generateFromContract(
+ "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml",
+ Map.of(CodegenConstants.OPENAPI_NULLABLE, "true",
+ CodegenConstants.GENERATE_JSON_INCLUDE_ANNOTATIONS, "true",
+ CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "SKIP"));
+
+ Path modelFile = files.get("TestModel.kt").toPath();
+ String content = Files.readString(modelFile);
+ int idx = content.indexOf("val optionalNonNullable:");
+ Assert.assertTrue(idx >= 0, "optionalNonNullable property must exist");
+ String context = content.substring(Math.max(0, idx - 200), idx);
+ Assert.assertTrue(context.contains("@field:JsonSetter(nulls = Nulls.SKIP)"),
+ "optionalNonNullPropertyJsonSetterNulls=SKIP must win over the openApiNullable=true FAIL default");
+ Assert.assertFalse(context.contains("@field:JsonSetter(nulls = Nulls.FAIL)"),
+ "FAIL must not be emitted when the option forces SKIP");
+ // @JsonInclude is still emitted independently
+ Assert.assertTrue(context.contains("@field:JsonInclude(JsonInclude.Include.NON_NULL)"),
+ "@JsonInclude(NON_NULL) must still be emitted independently of the setter-nulls option");
+ }
+
+ /**
+ * Issue #24491: {@code optionalNonNullPropertyJsonSetterNulls=FAIL} keeps FAIL under
+ * {@code openApiNullable=true} (matches the default, but now explicit).
+ */
+ @Test(description = "optionalNonNullPropertyJsonSetterNulls=FAIL with openApiNullable=true")
+ public void jsonSetterNulls_option_failWithOpenApiNullableTrue() throws IOException {
+ Map files = generateFromContract(
+ "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml",
+ Map.of(CodegenConstants.OPENAPI_NULLABLE, "true",
+ CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "FAIL"));
+
+ Path modelFile = files.get("TestModel.kt").toPath();
+ String content = Files.readString(modelFile);
+ int idx = content.indexOf("val optionalNonNullable:");
+ Assert.assertTrue(idx >= 0, "optionalNonNullable property must exist");
+ String context = content.substring(Math.max(0, idx - 200), idx);
+ Assert.assertTrue(context.contains("@field:JsonSetter(nulls = Nulls.FAIL)"),
+ "optionalNonNullPropertyJsonSetterNulls=FAIL must emit FAIL");
+ }
+
+ /**
+ * Issue #24491: {@code optionalNonNullPropertyJsonSetterNulls=FAIL} forces FAIL even with
+ * {@code openApiNullable=false}, where the default would be SKIP.
+ */
+ @Test(description = "optionalNonNullPropertyJsonSetterNulls=FAIL overrides the openApiNullable=false SKIP default")
+ public void jsonSetterNulls_option_failOverridesOpenApiNullableFalse() throws IOException {
+ Map files = generateFromContract(
+ "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml",
+ Map.of(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "FAIL"));
+
+ Path modelFile = files.get("TestModel.kt").toPath();
+ String content = Files.readString(modelFile);
+ int idx = content.indexOf("val optionalNonNullable:");
+ Assert.assertTrue(idx >= 0, "optionalNonNullable property must exist");
+ String context = content.substring(Math.max(0, idx - 200), idx);
+ Assert.assertTrue(context.contains("@field:JsonSetter(nulls = Nulls.FAIL)"),
+ "optionalNonNullPropertyJsonSetterNulls=FAIL must win over the openApiNullable=false SKIP default");
+ Assert.assertFalse(context.contains("@field:JsonSetter(nulls = Nulls.SKIP)"),
+ "SKIP must not be emitted when the option forces FAIL");
+ }
+
+ /**
+ * Issue #24491: an invalid {@code optionalNonNullPropertyJsonSetterNulls} value must fail fast with
+ * an actionable error message.
+ */
+ @Test(description = "invalid optionalNonNullPropertyJsonSetterNulls value fails fast")
+ public void jsonSetterNulls_option_invalidValueFailsFast() {
+ try {
+ generateFromContract(
+ "src/test/resources/3_0/kotlin/required-nullable-4-states.yaml",
+ Map.of(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "BOGUS"));
+ Assert.fail("expected an IllegalArgumentException for an invalid optionalNonNullPropertyJsonSetterNulls value");
+ } catch (Exception e) {
+ Throwable root = e;
+ while (root.getCause() != null) {
+ root = root.getCause();
+ }
+ Assert.assertTrue(root instanceof IllegalArgumentException,
+ "expected IllegalArgumentException but was " + root.getClass());
+ Assert.assertTrue(root.getMessage().contains("optionalNonNullPropertyJsonSetterNulls")
+ && root.getMessage().contains("[SKIP, FAIL]"),
+ "error message must name the option and allowed values, but was: " + root.getMessage());
+ }
+ }
+
+ /**
+ * Issue #24491: the per-property {@code x-jackson-json-setter-nulls} vendor extension precisely
+ * overrides the mode per field, winning over the option/openApiNullable default — including a NONE
+ * that emits no annotation, and a forced value on a required property.
+ */
+ @Test(description = "x-jackson-json-setter-nulls vendor extension overrides precisely per property")
+ public void jsonSetterNulls_perPropertyExtension() throws IOException {
+ Map files = generateFromContract(
+ "src/test/resources/3_0/kotlin/json-setter-nulls-override.yaml",
+ // openApiNullable=true (default FAIL) + option SKIP: per-property extension must still win
+ Map.of(CodegenConstants.OPENAPI_NULLABLE, "true",
+ CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true",
+ CodegenConstants.OPTIONAL_NON_NULL_PROPERTY_JSON_SETTER_NULLS, "SKIP"));
+
+ Path modelFile = files.get("TestModel.kt").toPath();
+ String content = Files.readString(modelFile);
+
+ assertPropertyHasSetterNulls(content, "manualSkip", "SKIP");
+ assertPropertyHasSetterNulls(content, "manualFail", "FAIL");
+ assertPropertyHasNoSetterNulls(content, "manualNone");
+ assertPropertyHasSetterNulls(content, "requiredForcedFail", "FAIL");
+ // plainOptional has no extension: follows the option (SKIP)
+ assertPropertyHasSetterNulls(content, "plainOptional", "SKIP");
+ }
+
+ /**
+ * Issue #24491: an invalid {@code x-jackson-json-setter-nulls} vendor extension value must fail fast.
*/
+ @Test(description = "invalid x-jackson-json-setter-nulls extension value fails fast")
+ public void jsonSetterNulls_perPropertyExtension_invalidValueFailsFast() {
+ try {
+ generateFromContract(
+ "src/test/resources/3_0/kotlin/json-setter-nulls-override-invalid.yaml",
+ Map.of(CodegenConstants.GENERATE_JSON_SETTER_NULLS_ANNOTATIONS, "true"));
+ Assert.fail("expected an IllegalArgumentException for an invalid x-jackson-json-setter-nulls value");
+ } catch (Exception e) {
+ Throwable root = e;
+ while (root.getCause() != null) {
+ root = root.getCause();
+ }
+ Assert.assertTrue(root instanceof IllegalArgumentException,
+ "expected IllegalArgumentException but was " + root.getClass());
+ Assert.assertTrue(root.getMessage().contains("x-jackson-json-setter-nulls"),
+ "error message must name the extension, but was: " + root.getMessage());
+ }
+ }
+
+ private static void assertPropertyHasSetterNulls(String content, String property, String mode) {
+ int idx = content.indexOf("val " + property + ":");
+ Assert.assertTrue(idx >= 0, property + " property must exist");
+ String context = content.substring(Math.max(0, idx - 250), idx);
+ Assert.assertTrue(context.contains("@field:JsonSetter(nulls = Nulls." + mode + ")"),
+ property + " must have @field:JsonSetter(nulls = Nulls." + mode + "), context was: " + context);
+ }
+
+ private static void assertPropertyHasNoSetterNulls(String content, String property) {
+ int idx = content.indexOf("val " + property + ":");
+ Assert.assertTrue(idx >= 0, property + " property must exist");
+ String context = content.substring(Math.max(0, idx - 250), idx);
+ Assert.assertFalse(context.contains("@field:JsonSetter(nulls"),
+ property + " must not have any @field:JsonSetter(nulls), context was: " + context);
+ }
@Test(description = "Scenario 4 – optional+nullable without openApiNullable: nullable type with null default (legacy fallback)")
public void requiredNullable_scenario4_optionalNullable_withoutOpenApiNullable() throws IOException {
Map files = generateFromContract(
@@ -6858,6 +7016,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);
@@ -6874,28 +7033,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);
}
/**
@@ -7182,6 +7558,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/java/org/openapitools/codegen/protobuf/ProtobufSchemaCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/protobuf/ProtobufSchemaCodegenTest.java
index e6c3d9721e03..7e9c4f786101 100644
--- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/protobuf/ProtobufSchemaCodegenTest.java
+++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/protobuf/ProtobufSchemaCodegenTest.java
@@ -312,7 +312,7 @@ public void unspecifiedEnumValuesAreAdded() {
Assert.assertEquals(enumVars1.get(0).get("name"), "UNSPECIFIED");
Assert.assertEquals(enumVars1.get(0).get("value"), "UNSPECIFIED");
- Assert.assertEquals(Boolean.valueOf((String) enumVars1.get(0).get("isString")), false);
+ Assert.assertEquals(enumVars1.get(0).get("isString"), false);
Assert.assertEquals(enumVars1.get(1).get("name"), "FOO");
Assert.assertEquals(enumVars1.get(1).get("value"), "FOO");
diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java
index 393965263778..958d9a66aedd 100644
--- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java
+++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java
@@ -151,20 +151,22 @@ public void verifyOneOfSupportWithCirce() throws IOException {
Path vehiclePath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Vehicle.scala");
assertFileContains(vehiclePath, "sealed trait Vehicle");
assertFileContains(vehiclePath, "object Vehicle {");
- assertFileContains(vehiclePath, "// oneOf with discriminator - using semiauto derivation with Configuration");
+ assertFileContains(vehiclePath, "// oneOf with discriminator");
assertFileContains(vehiclePath,
- "private implicit val config: Configuration = Configuration.default.withDiscriminator(\"vehicleType\")");
- assertFileContains(vehiclePath, "\"Car\" => \"car\"");
- assertFileContains(vehiclePath, "\"Truck\" => \"truck\"");
+ "case obj: Car => obj.asJson.mapObject((\"vehicleType\" -> \"car\".asJson) +: _)");
+ assertFileContains(vehiclePath,
+ "case obj: Truck => obj.asJson.mapObject((\"vehicleType\" -> \"truck\".asJson) +: _)");
+ assertFileContains(vehiclePath, "c.downField(\"vehicleType\").as[String].flatMap {");
+ assertFileContains(vehiclePath, "case \"car\" => c.as[Car].map(x => x: Vehicle)");
+ assertFileContains(vehiclePath, "case \"truck\" => c.as[Truck].map(x => x: Vehicle)");
// Test oneOf with discriminator that is a Scala keyword ("type")
// The discriminator should use the original wire name, not the backtick-escaped Scala name
Path shapePath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Shape.scala");
assertFileContains(shapePath, "sealed trait Shape");
- assertFileContains(shapePath,
- "private implicit val config: Configuration = Configuration.default.withDiscriminator(\"type\")");
+ assertFileContains(shapePath, "c.downField(\"type\").as[String].flatMap {");
// Discriminator in serialization must not be backtick-escaped
- assertFileNotContains(shapePath, "withDiscriminator(\"`type`\")");
+ assertFileNotContains(shapePath, "`type`");
// Verify regular models are still case classes
Path dogPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Dog.scala");
@@ -263,8 +265,8 @@ public void verifyOneOfWithEmptyMembers() throws IOException {
assertFileContains(eventPath, "case class PurchaseEvent(");
assertFileContains(eventPath, "amount: Double");
- // Verify discriminator is configured
- assertFileContains(eventPath, "Configuration.default.withDiscriminator(\"eventType\")");
+ // Verify the discriminator is written by the sealed trait's encoder
+ assertFileContains(eventPath, "c.downField(\"eventType\").as[String].flatMap {");
// Verify the discriminator property was removed from inline members
// ClickEvent and ViewEvent should have NO properties at all
@@ -408,6 +410,103 @@ public void verifyOptionalFieldsOmittedWhenNone() throws IOException {
// not serialized as null: strict servers reject explicit null for
// non-nullable optional properties.
Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala");
- assertFileContains(petPath, "implicit val encoder: Encoder[Pet] = deriveEncoder[Pet].mapJson(_.dropNullValues)");
+ assertFileContains(petPath, "t.tag.map(v => \"tag\" -> v.asJson)");
+ assertFileNotContains(petPath, "deriveEncoder");
+ }
+
+ @Test
+ public void verifyCirceCodecsUseOriginalJsonPropertyNames() throws IOException {
+ File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
+ output.deleteOnExit();
+ String outputPath = output.getAbsolutePath().replace('\\', '/');
+
+ OpenAPI openAPI = new OpenAPIParser()
+ .readLocation("src/test/resources/3_0/scala/sttp4-mixed-case-fields.yaml", null, new ParseOptions())
+ .getOpenAPI();
+
+ ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen();
+ codegen.setOutputDir(output.getAbsolutePath());
+ codegen.additionalProperties().put("jsonLibrary", "circe");
+
+ ClientOptInput input = new ClientOptInput();
+ input.openAPI(openAPI);
+ input.config(codegen);
+
+ DefaultGenerator generator = new DefaultGenerator();
+
+ generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true");
+ generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false");
+ generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false");
+ generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false");
+ generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false");
+ generator.opts(input).generate();
+
+ // Scala identifiers stay camelCase; JSON keys are the original spec property names
+ Path modelPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/MixedCaseModel.scala");
+ assertFileContains(modelPath, "assignmentKey: String");
+ assertFileContains(modelPath, "addressLine2: Option[String] = None");
+ assertFileContains(modelPath, "Some(\"assignment_key\" -> t.assignmentKey.asJson)");
+ assertFileContains(modelPath, "t.firstName.map(v => \"first-name\" -> v.asJson)");
+ assertFileContains(modelPath, "t.zipCode.map(v => \"ZipCode\" -> v.asJson)");
+ // an already-camelCase property must keep its name, so this stays byte-identical
+ assertFileContains(modelPath, "t.lastName.map(v => \"lastName\" -> v.asJson)");
+ assertFileContains(modelPath, "lastName <- c.downField(\"lastName\").as[Option[String]]");
+ assertFileContains(modelPath, "t.addressLine2.map(v => \"address_line_2\" -> v.asJson)");
+ assertFileContains(modelPath, "assignmentKey <- c.downField(\"assignment_key\").as[String]");
+ assertFileContains(modelPath, "addressLine2 <- c.downField(\"address_line_2\").as[Option[String]]");
+ assertFileContains(modelPath,
+ "bookingStatus <- c.downField(\"booking_status\").as[Option[MixedCaseModelEnums.BookingStatus]]");
+ assertFileNotContains(modelPath, "deriveEncoder");
+ assertFileNotContains(modelPath, "deriveDecoder");
+
+ // Inline oneOf members: original names too, and the discriminator property is
+ // written by the sealed trait rather than carried as a member field
+ Path paymentPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/PaymentMethod.scala");
+ assertFileContains(paymentPath, "Some(\"card_holder_name\" -> t.cardHolderName.asJson)");
+ assertFileContains(paymentPath, "t.lastFourDigits.map(v => \"last-four-digits\" -> v.asJson)");
+ assertFileContains(paymentPath,
+ "case obj: CreditCardPayment => obj.asJson.mapObject((\"payment_type\" -> \"credit_card\".asJson) +: _)");
+ assertFileContains(paymentPath, "c.downField(\"payment_type\").as[String].flatMap {");
+ assertFileNotContains(paymentPath, "paymentType");
+
+ // oneOf without a discriminator encodes the bare member. Deriving it produced a
+ // constructor-name wrapper ({"Circle":{..}}), which is not what oneOf means.
+ Path shapePath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Shape.scala");
+ assertFileContains(shapePath, "case obj: Circle => obj.asJson");
+ assertFileContains(shapePath, "Decoder[Circle].map(x => x: Shape)");
+ assertFileNotContains(shapePath, "deriveEncoder");
+ }
+
+ @Test
+ public void verifyModelNamedRequestDoesNotShadowSttpRequest() throws IOException {
+ File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
+ output.deleteOnExit();
+ String outputPath = output.getAbsolutePath().replace('\\', '/');
+
+ OpenAPI openAPI = new OpenAPIParser()
+ .readLocation("src/test/resources/3_0/scala/sttp4-request-model-name.yaml", null, new ParseOptions())
+ .getOpenAPI();
+
+ ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen();
+ codegen.setOutputDir(output.getAbsolutePath());
+
+ ClientOptInput input = new ClientOptInput();
+ input.openAPI(openAPI);
+ input.config(codegen);
+
+ DefaultGenerator generator = new DefaultGenerator();
+
+ generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true");
+ generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false");
+ generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false");
+ generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "true");
+ generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false");
+ generator.opts(input).generate();
+
+ // A model named Request is imported explicitly, which outranks the
+ // `import sttp.client4._` wildcard, so the return type must be qualified.
+ Path apiPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/api/DefaultApi.scala");
+ assertFileContains(apiPath, "import org.openapitools.client.model.Request");
+ assertFileContains(apiPath, "): sttp.client4.Request[Either[ResponseException[String], Response]] =");
}
}
diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/SharedTypeScriptTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/SharedTypeScriptTest.java
index 283b3932b5bc..32ce811ebe4d 100644
--- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/SharedTypeScriptTest.java
+++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/SharedTypeScriptTest.java
@@ -4,6 +4,7 @@
import java.io.File;
import java.io.IOException;
+import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -274,6 +275,32 @@ public void givenMapWithArrayOfEnumsThenCorrectEnumNameIsUsed() throws Exception
}
}
+ @Test(description = "Issue #23275 - as const enum objects should include trailing commas")
+ public void generatesTrailingCommasInAsConstEnumObjectsAcrossTypeScriptGenerators() throws Exception {
+ final String specPath = "src/test/resources/3_0/java/petstore-with-fake-endpoints-models-for-testing-with-http-signature.yaml";
+ List generators = Arrays.asList(
+ "typescript-angular",
+ "typescript-axios",
+ "typescript-fetch",
+ "typescript-nestjs-server"
+ );
+
+ for (String generatorName : generators) {
+ File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
+ output.deleteOnExit();
+
+ CodegenConfigurator configurator = new CodegenConfigurator()
+ .setGeneratorName(generatorName)
+ .setInputSpec(specPath)
+ .setOutputDir(output.getAbsolutePath());
+
+ Generator generator = new DefaultGenerator();
+ generator.opts(configurator.toClientOptInput()).generate();
+
+ assertAsConstObjectsUseTrailingCommas(output.toPath(), generatorName);
+ }
+ }
+
@Test(description = "Issue #22748 - Inner enums should not be double-prefixed when model has parent")
public void givenChildModelWithInheritedInnerEnumThenEnumNameIsNotDoublePrefixed() throws Exception {
// This tests that when a child model inherits from a parent that has an inner enum property,
@@ -310,4 +337,41 @@ public void givenChildModelWithInheritedInnerEnumThenEnumNameIsNotDoublePrefixed
"typescript-angular: Should contain 'Employee.ProjectRolesEnum' in " + modelFile);
}
+ private void assertAsConstObjectsUseTrailingCommas(Path root, String generatorName) throws IOException {
+ final int[] asConstObjectCount = {0};
+
+ try (Stream paths = Files.walk(root)) {
+ paths.filter(Files::isRegularFile)
+ .filter(path -> path.toString().endsWith(".ts"))
+ .forEach(path -> {
+ try {
+ List lines = Files.readAllLines(path);
+ for (int i = 0; i < lines.size(); i++) {
+ if (!lines.get(i).strip().equals("} as const;")) {
+ continue;
+ }
+
+ asConstObjectCount[0]++;
+
+ int previousLineIndex = i - 1;
+ while (previousLineIndex >= 0 && lines.get(previousLineIndex).isBlank()) {
+ previousLineIndex--;
+ }
+
+ Assert.assertTrue(
+ previousLineIndex >= 0 && lines.get(previousLineIndex).stripTrailing().endsWith(","),
+ generatorName + ": Expected trailing comma before '} as const;' in " + path);
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ });
+ } catch (UncheckedIOException e) {
+ throw e.getCause();
+ }
+
+ Assert.assertTrue(asConstObjectCount[0] > 0,
+ generatorName + ": Expected generated as const enum objects");
+ }
+
}
diff --git a/modules/openapi-generator/src/test/resources/3_0/kotlin/json-setter-nulls-override-invalid.yaml b/modules/openapi-generator/src/test/resources/3_0/kotlin/json-setter-nulls-override-invalid.yaml
new file mode 100644
index 000000000000..6da3bc472298
--- /dev/null
+++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/json-setter-nulls-override-invalid.yaml
@@ -0,0 +1,25 @@
+openapi: 3.0.0
+info:
+ title: Invalid x-jackson-json-setter-nulls value 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:
+ bad:
+ type: string
+ nullable: false
+ x-jackson-json-setter-nulls: BOGUS
diff --git a/modules/openapi-generator/src/test/resources/3_0/kotlin/json-setter-nulls-override.yaml b/modules/openapi-generator/src/test/resources/3_0/kotlin/json-setter-nulls-override.yaml
new file mode 100644
index 000000000000..4a09bac4f083
--- /dev/null
+++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/json-setter-nulls-override.yaml
@@ -0,0 +1,47 @@
+openapi: 3.0.0
+info:
+ title: Per-property x-jackson-json-setter-nulls 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
+ required:
+ - requiredForcedFail
+ properties:
+ # optional non-nullable with an explicit per-property SKIP override
+ manualSkip:
+ type: string
+ nullable: false
+ x-jackson-json-setter-nulls: SKIP
+ # optional non-nullable with an explicit per-property FAIL override
+ manualFail:
+ type: string
+ nullable: false
+ x-jackson-json-setter-nulls: FAIL
+ # optional non-nullable with a NONE override: no @JsonSetter must be emitted
+ manualNone:
+ type: string
+ nullable: false
+ x-jackson-json-setter-nulls: NONE
+ # required non-nullable forced to FAIL: the override is honored unconditionally
+ requiredForcedFail:
+ type: string
+ nullable: false
+ x-jackson-json-setter-nulls: FAIL
+ # a plain optional non-nullable property with no override for contrast
+ plainOptional:
+ type: string
+ nullable: false
diff --git a/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-mixed-case-fields.yaml b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-mixed-case-fields.yaml
new file mode 100644
index 000000000000..8bb63223a5ab
--- /dev/null
+++ b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-mixed-case-fields.yaml
@@ -0,0 +1,94 @@
+openapi: 3.0.0
+info:
+ title: sttp4 mixed case fields
+ version: 1.0.0
+paths:
+ /bookings:
+ post:
+ operationId: createBooking
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MixedCaseModel'
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PaymentMethod'
+components:
+ schemas:
+ MixedCaseModel:
+ type: object
+ required:
+ - assignment_key
+ properties:
+ assignment_key:
+ type: string
+ first-name:
+ type: string
+ phone_number:
+ type: string
+ lastName:
+ type: string
+ ZipCode:
+ type: string
+ address_line_2:
+ type: string
+ trip_summary:
+ $ref: '#/components/schemas/TripSummary'
+ booking_status:
+ type: string
+ enum:
+ - pending
+ - confirmed
+ TripSummary:
+ type: object
+ properties:
+ departure_airport_code:
+ type: string
+ PaymentMethod:
+ oneOf:
+ - $ref: '#/components/schemas/CreditCardPayment'
+ - $ref: '#/components/schemas/BankTransferPayment'
+ discriminator:
+ propertyName: payment_type
+ mapping:
+ credit_card: '#/components/schemas/CreditCardPayment'
+ bank_transfer: '#/components/schemas/BankTransferPayment'
+ CreditCardPayment:
+ type: object
+ required:
+ - card_holder_name
+ properties:
+ payment_type:
+ type: string
+ card_holder_name:
+ type: string
+ last-four-digits:
+ type: string
+ BankTransferPayment:
+ type: object
+ properties:
+ payment_type:
+ type: string
+ account_holder_name:
+ type: string
+ Shape:
+ oneOf:
+ - $ref: '#/components/schemas/Circle'
+ - $ref: '#/components/schemas/Square'
+ Circle:
+ type: object
+ required:
+ - radius_cm
+ properties:
+ radius_cm:
+ type: number
+ Square:
+ type: object
+ properties:
+ side_length:
+ type: number
diff --git a/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-request-model-name.yaml b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-request-model-name.yaml
new file mode 100644
index 000000000000..831244fbbc12
--- /dev/null
+++ b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-request-model-name.yaml
@@ -0,0 +1,34 @@
+openapi: 3.0.0
+info:
+ title: sttp4 model named Request
+ version: 1.0.0
+paths:
+ /predict:
+ post:
+ operationId: predict
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Request'
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Response'
+components:
+ schemas:
+ Request:
+ type: object
+ required:
+ - userId
+ properties:
+ userId:
+ type: string
+ Response:
+ type: object
+ properties:
+ score:
+ type: number
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..60c7ddac8262
--- /dev/null
+++ b/modules/openapi-generator/src/test/resources/3_0/spring/issue_24401_json_include_per_schema.yaml
@@ -0,0 +1,133 @@
+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