diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md
index b71808af91c3..3cc4c707399c 100644
--- a/docs/generators/java-camel.md
+++ b/docs/generators/java-camel.md
@@ -109,6 +109,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|useEnumCaseInsensitive|Use `equalsIgnoreCase` when String for enum comparison| |false|
|useFeignClientContextId|Whether to generate Feign client with contextId parameter.| |true|
|useFeignClientUrl|Whether to generate Feign client with url parameter.| |true|
+|useJSpecify|Use JSpecify's @Nullable (org.jspecify.annotations.Nullable) instead of Spring's @Nullable. Overrides any user-supplied importMapping for 'Nullable'.| |false|
|useJackson3|Set it in order to use jackson 3 dependencies (only allowed when `useSpringBoot4` is set and incompatible with `openApiNullable`).| |false|
|useJakartaEe|whether to use Jakarta EE namespace instead of javax| |false|
|useOneOfInterfaces|whether to use a java interface to describe a set of oneOf options, where each option is a class that implements the interface| |true|
diff --git a/docs/generators/spring.md b/docs/generators/spring.md
index 08649ac0e6a5..a37b6ad4302d 100644
--- a/docs/generators/spring.md
+++ b/docs/generators/spring.md
@@ -102,6 +102,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|useEnumCaseInsensitive|Use `equalsIgnoreCase` when String for enum comparison| |false|
|useFeignClientContextId|Whether to generate Feign client with contextId parameter.| |true|
|useFeignClientUrl|Whether to generate Feign client with url parameter.| |true|
+|useJSpecify|Use JSpecify's @Nullable (org.jspecify.annotations.Nullable) instead of Spring's @Nullable. Overrides any user-supplied importMapping for 'Nullable'.| |false|
|useJackson3|Set it in order to use jackson 3 dependencies (only allowed when `useSpringBoot4` is set and incompatible with `openApiNullable`).| |false|
|useJakartaEe|whether to use Jakarta EE namespace instead of javax| |false|
|useOneOfInterfaces|whether to use a java interface to describe a set of oneOf options, where each option is a class that implements the interface| |true|
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 d64785a8027c..bd39f8e3677a 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
@@ -109,6 +109,7 @@ public class SpringCodegen extends AbstractJavaCodegen
public static final String JACKSON3_PACKAGE = "tools.jackson";
public static final String JACKSON_PACKAGE = "jacksonPackage";
public static final String ADDITIONAL_NOT_NULL_ANNOTATIONS = "additionalNotNullAnnotations";
+ public static final String USE_JSPECIFY = "useJSpecify";
@Getter
public enum RequestMappingMode {
@@ -183,6 +184,8 @@ public enum RequestMappingMode {
protected boolean useJackson3 = false;
@Getter @Setter
protected boolean additionalNotNullAnnotations = false;
+ @Getter @Setter
+ protected boolean useJSpecify = false;
public SpringCodegen() {
super();
@@ -330,6 +333,10 @@ public SpringCodegen() {
cliOptions.add(CliOption.newBoolean(ADDITIONAL_NOT_NULL_ANNOTATIONS,
"Add @NotNull to path variables (required by default) and requestBody.",
additionalNotNullAnnotations));
+ cliOptions.add(CliOption.newBoolean(USE_JSPECIFY,
+ "Use JSpecify's @Nullable (org.jspecify.annotations.Nullable) instead of Spring's @Nullable. " +
+ "Overrides any user-supplied importMapping for 'Nullable'.",
+ isUseJSpecify()));
}
@@ -545,16 +552,21 @@ public void processOpts() {
convertPropertyToStringAndWriteBack(RESOURCE_FOLDER, this::setResourceFolder);
convertPropertyToBooleanAndWriteBack(ADDITIONAL_NOT_NULL_ANNOTATIONS, this::setAdditionalNotNullAnnotations);
+ convertPropertyToBooleanAndWriteBack(USE_JSPECIFY, this::setUseJSpecify);
// override parent one
importMapping.put("JsonDeserialize", (useJackson3 ? JACKSON3_PACKAGE : JACKSON2_PACKAGE) + ".databind.annotation.JsonDeserialize");
typeMapping.put("file", "org.springframework.core.io.Resource");
- importMapping.put("Nullable", "org.springframework.lang.Nullable");
importMapping.put("org.springframework.core.io.Resource", "org.springframework.core.io.Resource");
importMapping.put("DateTimeFormat", "org.springframework.format.annotation.DateTimeFormat");
importMapping.put("ApiIgnore", "springfox.documentation.annotations.ApiIgnore");
importMapping.put("ParameterObject", "org.springdoc.api.annotations.ParameterObject");
+ if (isUseJSpecify()) {
+ importMapping.put("Nullable", "org.jspecify.annotations.Nullable");
+ } else {
+ importMapping.put("Nullable", "org.springframework.lang.Nullable");
+ }
if (isUseSpringBoot3() || isUseSpringBoot4()) {
importMapping.put("ParameterObject", "org.springdoc.core.annotations.ParameterObject");
}
@@ -1019,10 +1031,39 @@ public void setParameterExampleValue(CodegenParameter p) {
}
}
+ /**
+ * Returns true if the nullableAnnotation.mustache partial would emit {@code @Nullable}
+ * for this property.
+ */
+ private boolean willEmitNullableAnnotation(CodegenProperty property) {
+ if (property.required) return false;
+ if (property.defaultValue == null) {
+ if (useOptional) return false;
+ if (isOpenApiNullable()) return !property.isNullable;
+ return true;
+ } else {
+ if (isOpenApiNullable()) return false;
+ return property.isNullable;
+ }
+ }
+
@Override
public void postProcessModelProperty(CodegenModel model, CodegenProperty property) {
super.postProcessModelProperty(model, property);
+ if (isUseJSpecify() && !property.isContainer && willEmitNullableAnnotation(property)) {
+ String datatype = property.datatypeWithEnum;
+ int lastDot = datatype.lastIndexOf('.');
+ if (lastDot >= 0) {
+ // Insert annotation between package path and simple class name: a.b.c.@Nullable TypeName
+ property.vendorExtensions.put("x-jspecify-annotated-type",
+ datatype.substring(0, lastDot + 1) + "@Nullable " + datatype.substring(lastDot + 1));
+ } else {
+ // No package qualifier – annotation before type is still valid
+ property.vendorExtensions.put("x-jspecify-annotated-type", "@Nullable " + datatype);
+ }
+ }
+
// add org.springframework.format.annotation.DateTimeFormat when needed
if (property.isDate || property.isDateTime) {
model.imports.add("DateTimeFormat");
diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom-sb3.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom-sb3.mustache
index 747c9272cc1a..100e09ddc8f2 100644
--- a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom-sb3.mustache
+++ b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom-sb3.mustache
@@ -164,6 +164,14 @@
jsr305
3.0.2
+ {{#useJSpecify}}
+
+
+ org.jspecify
+ jspecify
+ 1.0.0
+
+ {{/useJSpecify}}
{{#withXml}}
diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom.mustache
index 38c29f3648a3..f0f3157e64f6 100644
--- a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom.mustache
+++ b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-boot/pom.mustache
@@ -194,6 +194,14 @@
jsr305
3.0.2
+ {{#useJSpecify}}
+
+
+ org.jspecify
+ jspecify
+ 1.0.0
+
+ {{/useJSpecify}}
{{#withXml}}
diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom-sb3.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom-sb3.mustache
index d62bb06bf8a9..71a3fc2385f2 100644
--- a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom-sb3.mustache
+++ b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom-sb3.mustache
@@ -92,6 +92,14 @@
3.0.2
{{/parentOverridden}}
+ {{#useJSpecify}}
+
+
+ org.jspecify
+ jspecify
+ 1.0.0
+
+ {{/useJSpecify}}
org.springframework.cloud
spring-cloud-starter-openfeign
diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom.mustache
index 060c2e6ef164..1e018fc6229b 100644
--- a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom.mustache
+++ b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-cloud/pom.mustache
@@ -122,6 +122,14 @@
3.0.2
{{/parentOverridden}}
+ {{#useJSpecify}}
+
+
+ org.jspecify
+ jspecify
+ 1.0.0
+
+ {{/useJSpecify}}
org.springframework.cloud
spring-cloud-starter-openfeign
diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-http-interface/pom.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-http-interface/pom.mustache
index 7a131774517c..b4a376138037 100644
--- a/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-http-interface/pom.mustache
+++ b/modules/openapi-generator/src/main/resources/JavaSpring/libraries/spring-http-interface/pom.mustache
@@ -63,6 +63,14 @@
jsr305
3.0.2
+ {{#useJSpecify}}
+
+
+ org.jspecify
+ jspecify
+ 1.0.0
+
+ {{/useJSpecify}}
jakarta.validation
jakarta.validation-api
diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache
index 8a93fb054f1f..a7a55fa7a25c 100644
--- a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache
+++ b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache
@@ -70,7 +70,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
private {{>nullableAnnotation}}{{#isNullable}}{{>nullableDataTypeBeanValidation}} {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>undefined();{{/isNullable}}{{^required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}}{{#required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}}
{{/openApiNullable}}
{{^openApiNullable}}
- private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
+ private {{#vendorExtensions.x-jspecify-annotated-type}}{{{.}}}{{/vendorExtensions.x-jspecify-annotated-type}}{{^vendorExtensions.x-jspecify-annotated-type}}{{>nullableAnnotation}}{{>nullableDataType}}{{/vendorExtensions.x-jspecify-annotated-type}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
{{/openApiNullable}}
{{/isContainer}}
{{^isContainer}}
@@ -84,7 +84,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
private {{>nullableAnnotation}}{{#isNullable}}{{>nullableDataTypeBeanValidation}} {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>undefined();{{/isNullable}}{{^required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#useOptional}} = Optional.{{^defaultValue}}empty(){{/defaultValue}}{{#defaultValue}}of({{{.}}}){{/defaultValue}};{{/useOptional}}{{^useOptional}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/useOptional}}{{/isNullable}}{{/required}}{{^isNullable}}{{#required}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/required}}{{/isNullable}}
{{/openApiNullable}}
{{^openApiNullable}}
- private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#isNullable}} = null{{/isNullable}}{{^isNullable}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{/isNullable}};
+ {{#vendorExtensions.x-jspecify-annotated-type}}private {{{vendorExtensions.x-jspecify-annotated-type}}} {{name}}{{#isNullable}} = null{{/isNullable}}{{^isNullable}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{/isNullable}};{{/vendorExtensions.x-jspecify-annotated-type}}{{^vendorExtensions.x-jspecify-annotated-type}}private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#isNullable}} = null{{/isNullable}}{{^isNullable}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{/isNullable}};{{/vendorExtensions.x-jspecify-annotated-type}}
{{/openApiNullable}}
{{/isContainer}}
{{/vars}}
@@ -144,7 +144,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
{{^lombok.Data}}
{{! begin feature: fluent setter methods }}
- public {{classname}} {{name}}({{>nullableAnnotation}}{{{datatypeWithEnum}}} {{name}}) {
+ public {{classname}} {{name}}({{#vendorExtensions.x-jspecify-annotated-type}}{{{vendorExtensions.x-jspecify-annotated-type}}}{{/vendorExtensions.x-jspecify-annotated-type}}{{^vendorExtensions.x-jspecify-annotated-type}}{{>nullableAnnotation}}{{{datatypeWithEnum}}}{{/vendorExtensions.x-jspecify-annotated-type}} {{name}}) {
{{#openApiNullable}}
this.{{name}} = {{#isNullable}}JsonNullable.of({{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}Optional.of{{#optionalAcceptNullable}}Nullable{{/optionalAcceptNullable}}({{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{name}}{{#isNullable}}){{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}){{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}};
{{/openApiNullable}}
@@ -229,7 +229,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
{{#deprecated}}
@Deprecated
{{/deprecated}}
-{{#jackson}}{{>jackson_annotations}}{{/jackson}}{{#withXml}}{{>xmlAccessorAnnotation}}{{/withXml}} public {{>nullableAnnotation}}{{>nullableDataTypeBeanValidation}} {{getter}}() {
+{{#jackson}}{{>jackson_annotations}}{{/jackson}}{{#withXml}}{{>xmlAccessorAnnotation}}{{/withXml}} public {{#vendorExtensions.x-jspecify-annotated-type}}{{{vendorExtensions.x-jspecify-annotated-type}}}{{/vendorExtensions.x-jspecify-annotated-type}}{{^vendorExtensions.x-jspecify-annotated-type}}{{>nullableAnnotation}}{{>nullableDataTypeBeanValidation}}{{/vendorExtensions.x-jspecify-annotated-type}} {{getter}}() {
return {{name}};
}
{{/lombok.Getter}}
@@ -246,7 +246,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
{{#deprecated}}
@Deprecated
{{/deprecated}}
-{{#jackson}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{>jackson_annotations}}{{/vendorExtensions.x-is-jackson-optional-nullable}}{{/jackson}} public void {{setter}}({{>nullableAnnotation}}{{>nullableDataType}} {{name}}) {
+{{#jackson}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{>jackson_annotations}}{{/vendorExtensions.x-is-jackson-optional-nullable}}{{/jackson}} public void {{setter}}({{#vendorExtensions.x-jspecify-annotated-type}}{{{vendorExtensions.x-jspecify-annotated-type}}}{{/vendorExtensions.x-jspecify-annotated-type}}{{^vendorExtensions.x-jspecify-annotated-type}}{{>nullableAnnotation}}{{>nullableDataType}}{{/vendorExtensions.x-jspecify-annotated-type}} {{name}}) {
this.{{name}} = {{name}};
}
{{/lombok.Setter}}
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 032c192fac42..76581e9ef1c6 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
@@ -6637,4 +6637,35 @@ public void shouldNotHaveDocumentationAnnotationWhenUsingLibrarySpringHttpInterf
JavaFileAssert.assertThat(Paths.get(outputPath + "/src/main/java/org/openapitools/api/PetApi.java"))
.assertMethod("addPet").assertParameter("pet").assertParameterAnnotations().doesNotContainWithName("Parameter");
}
+
+ @Test
+ public void testIssue23206() throws IOException {
+ final SpringCodegen codegen = new SpringCodegen();
+
+ codegen.setLibrary(SPRING_BOOT);
+
+ codegen.schemaMapping().put("SchemaMappedDatatype", "a.b.c.SchemaMappedDatatype");
+
+ codegen.additionalProperties().put(OPENAPI_NULLABLE, "false");
+ codegen.additionalProperties().put(SKIP_DEFAULT_INTERFACE, "true");
+ codegen.additionalProperties().put(USE_SPRING_BOOT4, "true");
+ codegen.additionalProperties().put(USE_JACKSON_3, "true");
+ codegen.additionalProperties().put(USE_TAGS, "true");
+ codegen.additionalProperties().put(USE_BEANVALIDATION, "false");
+ codegen.additionalProperties().put(USE_JSPECIFY, "true");
+
+ final Map files = generateFiles(codegen, "src/test/resources/3_0/spring/issue_23206.yaml");
+ final var javaFileAssert = JavaFileAssert.assertThat(files.get("SchemaMappedWithTypeUseAnnotationDto.java"));
+ javaFileAssert
+ .hasImports("org.jspecify.annotations.Nullable")
+ .assertProperty("schemaMappedDatatype");
+
+ javaFileAssert.fileContains("private a.b.c.@Nullable SchemaMappedDatatype schemaMappedDatatype = null;");
+ javaFileAssert.fileContains("public a.b.c.@Nullable SchemaMappedDatatype getSchemaMappedDatatype() {");
+ javaFileAssert.fileContains("public void setSchemaMappedDatatype(a.b.c.@Nullable SchemaMappedDatatype schemaMappedDatatype) {");
+
+ javaFileAssert.fileContains("private @Nullable BigDecimal importedDatatype = null;");
+ javaFileAssert.fileContains("public @Nullable BigDecimal getImportedDatatype() {");
+ javaFileAssert.fileContains("public void setImportedDatatype(@Nullable BigDecimal importedDatatype) {");
+ }
}
diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/issue_23206.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/issue_23206.yaml
new file mode 100644
index 000000000000..e98418179743
--- /dev/null
+++ b/modules/openapi-generator/src/test/resources/3_0/spring/issue_23206.yaml
@@ -0,0 +1,28 @@
+openapi: "3.0.3"
+info:
+ title: Move Annotations in Generated POJOs
+ description: "Test Placement of Annotations, to allow TYPE_USE Annotations to compile correctly. See #23206"
+ version: 1.0.0
+
+paths:
+ /some/endpoint:
+ get:
+ responses:
+ "200":
+ description: OK
+
+components:
+ schemas:
+ SchemaMappedWithTypeUseAnnotationDto:
+ type: object
+ properties:
+ schemaMappedDatatype:
+ $ref: '#/components/schemas/SchemaMappedDatatype'
+ importedDatatype:
+ $ref: '#/components/schemas/ImportedDatatype'
+ SchemaMappedDatatype:
+ nullable: true
+ type: number
+ ImportedDatatype:
+ nullable: true
+ type: number