Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/generators/java-camel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
1 change: 1 addition & 0 deletions docs/generators/spring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: useJSpecify is documented and generates JSpecify annotation usage, but Spring build templates do not appear to add the required org.jspecify:jspecify dependency, risking uncompilable generated projects.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/generators/spring.md, line 105:

<comment>`useJSpecify` is documented and generates JSpecify annotation usage, but Spring build templates do not appear to add the required `org.jspecify:jspecify` dependency, risking uncompilable generated projects.</comment>

<file context>
@@ -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|
</file context>
Fix with Cubic

|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|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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()));

}

Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}};
{{#vendorExtensions.x-jspecify-annotated-type}}private {{{vendorExtensions.x-jspecify-annotated-type}}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/vendorExtensions.x-jspecify-annotated-type}}{{^vendorExtensions.x-jspecify-annotated-type}}private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/vendorExtensions.x-jspecify-annotated-type}}
Comment thread
felldo marked this conversation as resolved.
Outdated
{{/openApiNullable}}
{{/isContainer}}
{{^isContainer}}
Expand All @@ -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}}
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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}}
Expand All @@ -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}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, File> 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) {");
}
}
Original file line number Diff line number Diff line change
@@ -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