Skip to content

Commit 1282db0

Browse files
committed
Add support for JSpecify annotations in SpringCodegen
- Introduced `useJSpecify` flag to enable the use of JSpecify's `@Nullable` annotations. - Modified POJO templates to correctly place JSpecify annotations for `TYPE_USE` compatibility. - Updated importMappings to handle hardcoded JSpecify annotations when enabled. - Added a test case for verifying correct annotation placement and behavior (`issue_23206.yaml` and corresponding Java test).
1 parent 0c31459 commit 1282db0

6 files changed

Lines changed: 108 additions & 6 deletions

File tree

docs/generators/java-camel.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
109109
|useEnumCaseInsensitive|Use `equalsIgnoreCase` when String for enum comparison| |false|
110110
|useFeignClientContextId|Whether to generate Feign client with contextId parameter.| |true|
111111
|useFeignClientUrl|Whether to generate Feign client with url parameter.| |true|
112+
|useJSpecify|Use JSpecify's @Nullable (org.jspecify.annotations.Nullable) instead of Spring's @Nullable. Overrides any user-supplied importMapping for 'Nullable'.| |false|
112113
|useJackson3|Set it in order to use jackson 3 dependencies (only allowed when `useSpringBoot4` is set and incompatible with `openApiNullable`).| |false|
113114
|useJakartaEe|whether to use Jakarta EE namespace instead of javax| |false|
114115
|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|

docs/generators/spring.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
102102
|useEnumCaseInsensitive|Use `equalsIgnoreCase` when String for enum comparison| |false|
103103
|useFeignClientContextId|Whether to generate Feign client with contextId parameter.| |true|
104104
|useFeignClientUrl|Whether to generate Feign client with url parameter.| |true|
105+
|useJSpecify|Use JSpecify's @Nullable (org.jspecify.annotations.Nullable) instead of Spring's @Nullable. Overrides any user-supplied importMapping for 'Nullable'.| |false|
105106
|useJackson3|Set it in order to use jackson 3 dependencies (only allowed when `useSpringBoot4` is set and incompatible with `openApiNullable`).| |false|
106107
|useJakartaEe|whether to use Jakarta EE namespace instead of javax| |false|
107108
|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|

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ public class SpringCodegen extends AbstractJavaCodegen
109109
public static final String JACKSON3_PACKAGE = "tools.jackson";
110110
public static final String JACKSON_PACKAGE = "jacksonPackage";
111111
public static final String ADDITIONAL_NOT_NULL_ANNOTATIONS = "additionalNotNullAnnotations";
112+
public static final String USE_JSPECIFY = "useJSpecify";
112113

113114
@Getter
114115
public enum RequestMappingMode {
@@ -183,6 +184,8 @@ public enum RequestMappingMode {
183184
protected boolean useJackson3 = false;
184185
@Getter @Setter
185186
protected boolean additionalNotNullAnnotations = false;
187+
@Getter @Setter
188+
protected boolean useJSpecify = false;
186189

187190
public SpringCodegen() {
188191
super();
@@ -330,6 +333,10 @@ public SpringCodegen() {
330333
cliOptions.add(CliOption.newBoolean(ADDITIONAL_NOT_NULL_ANNOTATIONS,
331334
"Add @NotNull to path variables (required by default) and requestBody.",
332335
additionalNotNullAnnotations));
336+
cliOptions.add(CliOption.newBoolean(USE_JSPECIFY,
337+
"Use JSpecify's @Nullable (org.jspecify.annotations.Nullable) instead of Spring's @Nullable. " +
338+
"Overrides any user-supplied importMapping for 'Nullable'.",
339+
isUseJSpecify()));
333340

334341
}
335342

@@ -545,16 +552,21 @@ public void processOpts() {
545552
convertPropertyToStringAndWriteBack(RESOURCE_FOLDER, this::setResourceFolder);
546553

547554
convertPropertyToBooleanAndWriteBack(ADDITIONAL_NOT_NULL_ANNOTATIONS, this::setAdditionalNotNullAnnotations);
555+
convertPropertyToBooleanAndWriteBack(USE_JSPECIFY, this::setUseJSpecify);
548556

549557
// override parent one
550558
importMapping.put("JsonDeserialize", (useJackson3 ? JACKSON3_PACKAGE : JACKSON2_PACKAGE) + ".databind.annotation.JsonDeserialize");
551559

552560
typeMapping.put("file", "org.springframework.core.io.Resource");
553-
importMapping.put("Nullable", "org.springframework.lang.Nullable");
554561
importMapping.put("org.springframework.core.io.Resource", "org.springframework.core.io.Resource");
555562
importMapping.put("DateTimeFormat", "org.springframework.format.annotation.DateTimeFormat");
556563
importMapping.put("ApiIgnore", "springfox.documentation.annotations.ApiIgnore");
557564
importMapping.put("ParameterObject", "org.springdoc.api.annotations.ParameterObject");
565+
if (isUseJSpecify()) {
566+
importMapping.put("Nullable", "org.jspecify.annotations.Nullable");
567+
} else {
568+
importMapping.put("Nullable", "org.springframework.lang.Nullable");
569+
}
558570
if (isUseSpringBoot3() || isUseSpringBoot4()) {
559571
importMapping.put("ParameterObject", "org.springdoc.core.annotations.ParameterObject");
560572
}
@@ -1019,10 +1031,39 @@ public void setParameterExampleValue(CodegenParameter p) {
10191031
}
10201032
}
10211033

1034+
/**
1035+
* Returns true if the nullableAnnotation.mustache partial would emit {@code @Nullable}
1036+
* for this property.
1037+
*/
1038+
private boolean willEmitNullableAnnotation(CodegenProperty property) {
1039+
if (property.required) return false;
1040+
if (property.defaultValue == null) {
1041+
if (useOptional) return false;
1042+
if (isOpenApiNullable()) return !property.isNullable;
1043+
return true;
1044+
} else {
1045+
if (isOpenApiNullable()) return false;
1046+
return property.isNullable;
1047+
}
1048+
}
1049+
10221050
@Override
10231051
public void postProcessModelProperty(CodegenModel model, CodegenProperty property) {
10241052
super.postProcessModelProperty(model, property);
10251053

1054+
if (isUseJSpecify() && !property.isContainer && willEmitNullableAnnotation(property)) {
1055+
String datatype = property.datatypeWithEnum;
1056+
int lastDot = datatype.lastIndexOf('.');
1057+
if (lastDot >= 0) {
1058+
// Insert annotation between package path and simple class name: a.b.c.@Nullable TypeName
1059+
property.vendorExtensions.put("x-jspecify-annotated-type",
1060+
datatype.substring(0, lastDot + 1) + "@Nullable " + datatype.substring(lastDot + 1));
1061+
} else {
1062+
// No package qualifier – annotation before type is still valid
1063+
property.vendorExtensions.put("x-jspecify-annotated-type", "@Nullable " + datatype);
1064+
}
1065+
}
1066+
10261067
// add org.springframework.format.annotation.DateTimeFormat when needed
10271068
if (property.isDate || property.isDateTime) {
10281069
model.imports.add("DateTimeFormat");

modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
7070
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}}
7171
{{/openApiNullable}}
7272
{{^openApiNullable}}
73-
private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
73+
{{#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}}
7474
{{/openApiNullable}}
7575
{{/isContainer}}
7676
{{^isContainer}}
@@ -84,7 +84,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
8484
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}}
8585
{{/openApiNullable}}
8686
{{^openApiNullable}}
87-
private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#isNullable}} = null{{/isNullable}}{{^isNullable}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{/isNullable}};
87+
{{#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}}
8888
{{/openApiNullable}}
8989
{{/isContainer}}
9090
{{/vars}}
@@ -144,7 +144,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
144144
{{^lombok.Data}}
145145

146146
{{! begin feature: fluent setter methods }}
147-
public {{classname}} {{name}}({{>nullableAnnotation}}{{{datatypeWithEnum}}} {{name}}) {
147+
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}}) {
148148
{{#openApiNullable}}
149149
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}};
150150
{{/openApiNullable}}
@@ -229,7 +229,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
229229
{{#deprecated}}
230230
@Deprecated
231231
{{/deprecated}}
232-
{{#jackson}}{{>jackson_annotations}}{{/jackson}}{{#withXml}}{{>xmlAccessorAnnotation}}{{/withXml}} public {{>nullableAnnotation}}{{>nullableDataTypeBeanValidation}} {{getter}}() {
232+
{{#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}}() {
233233
return {{name}};
234234
}
235235
{{/lombok.Getter}}
@@ -246,7 +246,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
246246
{{#deprecated}}
247247
@Deprecated
248248
{{/deprecated}}
249-
{{#jackson}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{>jackson_annotations}}{{/vendorExtensions.x-is-jackson-optional-nullable}}{{/jackson}} public void {{setter}}({{>nullableAnnotation}}{{>nullableDataType}} {{name}}) {
249+
{{#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}}) {
250250
this.{{name}} = {{name}};
251251
}
252252
{{/lombok.Setter}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6637,4 +6637,35 @@ public void shouldNotHaveDocumentationAnnotationWhenUsingLibrarySpringHttpInterf
66376637
JavaFileAssert.assertThat(Paths.get(outputPath + "/src/main/java/org/openapitools/api/PetApi.java"))
66386638
.assertMethod("addPet").assertParameter("pet").assertParameterAnnotations().doesNotContainWithName("Parameter");
66396639
}
6640+
6641+
@Test
6642+
public void testIssue23206() throws IOException {
6643+
final SpringCodegen codegen = new SpringCodegen();
6644+
6645+
codegen.setLibrary(SPRING_BOOT);
6646+
6647+
codegen.schemaMapping().put("SchemaMappedDatatype", "a.b.c.SchemaMappedDatatype");
6648+
6649+
codegen.additionalProperties().put(OPENAPI_NULLABLE, "false");
6650+
codegen.additionalProperties().put(SKIP_DEFAULT_INTERFACE, "true");
6651+
codegen.additionalProperties().put(USE_SPRING_BOOT4, "true");
6652+
codegen.additionalProperties().put(USE_JACKSON_3, "true");
6653+
codegen.additionalProperties().put(USE_TAGS, "true");
6654+
codegen.additionalProperties().put(USE_BEANVALIDATION, "false");
6655+
codegen.additionalProperties().put(USE_JSPECIFY, "true");
6656+
6657+
final Map<String, File> files = generateFiles(codegen, "src/test/resources/3_0/spring/issue_23206.yaml");
6658+
final var javaFileAssert = JavaFileAssert.assertThat(files.get("SchemaMappedWithTypeUseAnnotationDto.java"));
6659+
javaFileAssert
6660+
.hasImports("org.jspecify.annotations.Nullable")
6661+
.assertProperty("schemaMappedDatatype");
6662+
6663+
javaFileAssert.fileContains("private a.b.c.@Nullable SchemaMappedDatatype schemaMappedDatatype = null;");
6664+
javaFileAssert.fileContains("public a.b.c.@Nullable SchemaMappedDatatype getSchemaMappedDatatype() {");
6665+
javaFileAssert.fileContains("public void setSchemaMappedDatatype(a.b.c.@Nullable SchemaMappedDatatype schemaMappedDatatype) {");
6666+
6667+
javaFileAssert.fileContains("private @Nullable BigDecimal importedDatatype = null;");
6668+
javaFileAssert.fileContains("public @Nullable BigDecimal getImportedDatatype() {");
6669+
javaFileAssert.fileContains("public void setImportedDatatype(@Nullable BigDecimal importedDatatype) {");
6670+
}
66406671
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
openapi: "3.0.3"
2+
info:
3+
title: Move Annotations in Generated POJOs
4+
description: "Test Placement of Annotations, to allow TYPE_USE Annotations to compile correctly. See #23206"
5+
version: 1.0.0
6+
7+
paths:
8+
/some/endpoint:
9+
get:
10+
responses:
11+
"200":
12+
description: OK
13+
14+
components:
15+
schemas:
16+
SchemaMappedWithTypeUseAnnotationDto:
17+
type: object
18+
properties:
19+
schemaMappedDatatype:
20+
$ref: '#/components/schemas/SchemaMappedDatatype'
21+
importedDatatype:
22+
$ref: '#/components/schemas/ImportedDatatype'
23+
SchemaMappedDatatype:
24+
nullable: true
25+
type: number
26+
ImportedDatatype:
27+
nullable: true
28+
type: number

0 commit comments

Comments
 (0)