Skip to content

Commit

Permalink
Adds @nullable annotation to Spring Boot generator (#20345)
Browse files Browse the repository at this point in the history
* Adds @nullable annotation to Spring Boot generator

* issue-14427: [REQ][spring] Null-Safety annotations
* issue-17382: [REQ] spring generator add Nullable annotations

Motivations:
* Have Spring Boot generator client properly annotated for nullability to be able to check code using them with tools like NullAway
* As it is related to Spring then the `org.springframework.lang.Nullable` annotation was chosen to avoid discussion which `@Nullable` one is true one
* `@NonNull` wasn't used as I didn't see much benefit of it. Anyhow, an empty constructor and/or setters allow to put a `null` value there

Modifications:
* Adds nullableAnnotation template to handle nullability annotation on vars
* Adjust pojo templates to use the nullability template
* Adapts tests

Modifications:
* Runs export_docs_generator.sh script to update samples

* samples update

* excludes Spring @nullable from java-camel

* ones with defaults shouldn't be annotated as @nullable

* updates samples

* adds AllArgConstructor generation tests

* adds container tests
  • Loading branch information
slobodator authored Jan 6, 2025
1 parent 4b5dfc4 commit cba756f
Show file tree
Hide file tree
Showing 1,181 changed files with 4,056 additions and 2,671 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ public void processOpts() {
convertPropertyToStringAndWriteBack(RESOURCE_FOLDER, this::setResourceFolder);

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");
Expand Down Expand Up @@ -952,6 +953,11 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert
if (model.getVendorExtensions().containsKey("x-jackson-optional-nullable-helpers")) {
model.imports.add("Arrays");
}

// to prevent inheritors (JavaCamelServerCodegen etc.) mistakenly use it
if (getName().contains("spring")) {
model.imports.add("Nullable");
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{^required}}{{^defaultValue}}{{^useOptional}}{{#openApiNullable}}{{^isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{^openApiNullable}}@Nullable {{/openApiNullable}}{{/useOptional}}{{/defaultValue}}{{#defaultValue}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/defaultValue}}{{/required}}
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ public class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}{{^parent}}
{{#isContainer}}
{{#useBeanValidation}}@Valid{{/useBeanValidation}}
{{#openApiNullable}}
private {{#isNullable}}{{>nullableDataTypeBeanValidation}} {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>undefined();{{/isNullable}}{{^required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}}{{#required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}}
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 {{>nullableDataType}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
{{/openApiNullable}}
{{/isContainer}}
{{^isContainer}}
Expand All @@ -86,10 +86,10 @@ public class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}{{^parent}}
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
{{/isDateTime}}
{{#openApiNullable}}
private {{#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}}
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 {{>nullableDataType}} {{name}}{{#isNullable}} = null{{/isNullable}}{{^isNullable}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{/isNullable}};
private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#isNullable}} = null{{/isNullable}}{{^isNullable}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{/isNullable}};
{{/openApiNullable}}
{{/isContainer}}
{{/vars}}
Expand Down Expand Up @@ -130,7 +130,7 @@ public class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}{{^parent}}
/**
* Constructor with all args parameters
*/
public {{classname}}({{#vendorExtensions.x-java-all-args-constructor-vars}}{{{datatypeWithEnum}}} {{name}}{{^-last}}, {{/-last}}{{/vendorExtensions.x-java-all-args-constructor-vars}}) {
public {{classname}}({{#vendorExtensions.x-java-all-args-constructor-vars}}{{>nullableAnnotation}}{{{datatypeWithEnum}}} {{name}}{{^-last}}, {{/-last}}{{/vendorExtensions.x-java-all-args-constructor-vars}}) {
{{#parent}}
super({{#parentVars}}{{name}}{{^-last}}, {{/-last}}{{/parentVars}});
{{/parent}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,18 @@ public PropertyAssert withType(final String expectedType) {
public PropertyAnnotationsAssert assertPropertyAnnotations() {
return new PropertyAnnotationsAssert(this, actual.getAnnotations());
}

public PropertyAnnotationsAssert doesNotHaveAnnotation(String annotationName) {
return new PropertyAnnotationsAssert(
this,
actual.getAnnotations()
).doesNotContainWithName(annotationName);
}

public PropertyAnnotationsAssert hasAnnotation(String annotationName) {
return new PropertyAnnotationsAssert(
this,
actual.getAnnotations()
).containsWithName(annotationName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4893,14 +4893,14 @@ public void testCollectionTypesWithDefaults_issue_18102() throws IOException {
.collect(Collectors.toMap(File::getName, Function.identity()));

JavaFileAssert.assertThat(files.get("PetDto.java"))
.fileContains("private List<@Valid TagDto> tags")
.fileContains("private @Nullable List<@Valid TagDto> tags")
.fileContains("private List<@Valid TagDto> tagsDefaultList = new ArrayList<>()")
.fileContains("private Set<@Valid TagDto> tagsUnique")
.fileContains("private @Nullable Set<@Valid TagDto> tagsUnique")
.fileContains("private Set<@Valid TagDto> tagsDefaultSet = new LinkedHashSet<>();")
.fileContains("private List<String> stringList")
.fileContains("private @Nullable List<String> stringList")
.fileContains("private List<String> stringDefaultList = new ArrayList<>(Arrays.asList(\"A\", \"B\"));")
.fileContains("private List<String> stringEmptyDefaultList = new ArrayList<>();")
.fileContains("Set<String> stringSet")
.fileContains("@Nullable Set<String> stringSet")
.fileContains("private Set<String> stringDefaultSet = new LinkedHashSet<>(Arrays.asList(\"A\", \"B\"));")
.fileContains("private Set<String> stringEmptyDefaultSet = new LinkedHashSet<>();")
.fileDoesNotContain("private List<@Valid TagDto> tags = new ArrayList<>()")
Expand Down Expand Up @@ -5099,4 +5099,156 @@ public void testEnumUnknownDefaultCaseDeserializationNotSet_issue13241() throws
.assertMethod("build")
.doesNotHaveAnnotation("Deprecated");
}

@Test
public void shouldAnnotateNonRequiredFieldsAsNullable() throws IOException {
SpringCodegen codegen = new SpringCodegen();
codegen.setLibrary(SPRING_BOOT);
codegen.setGenerateConstructorWithAllArgs(true);

Map<String, File> files = generateFiles(codegen, "src/test/resources/3_0/nullable-annotation.yaml");
var file = files.get("Item.java");

JavaFileAssert.assertThat(file)
.assertProperty("mandatoryName")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalDescription")
.hasAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalOneWithDefault")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("nullableStr")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("mandatoryContainer")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalContainer")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalContainerWithDefault")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("nullableContainer")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.fileContains(
"public Item(" +
"String mandatoryName," +
" @Nullable String optionalDescription," +
" String optionalOneWithDefault," +
" String nullableStr," +
" List<String> mandatoryContainer," +
" List<String> optionalContainer," +
" List<String> optionalContainerWithDefault," +
" List<String> nullableContainer)"
);
}

@Test
public void shouldAnnotateNonRequiredFieldsAsNullableWhenSetContainerDefaultToNull() throws IOException {
SpringCodegen codegen = new SpringCodegen();
codegen.setLibrary(SPRING_BOOT);
codegen.setGenerateConstructorWithAllArgs(true);
codegen.setContainerDefaultToNull(true);

Map<String, File> files = generateFiles(codegen, "src/test/resources/3_0/nullable-annotation.yaml");
var file = files.get("Item.java");

JavaFileAssert.assertThat(file)
.assertProperty("mandatoryContainer")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalContainer")
.hasAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalContainerWithDefault")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("nullableContainer")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.fileContains(
", List<String> mandatoryContainer," +
" @Nullable List<String> optionalContainer," +
" List<String> optionalContainerWithDefault," +
" List<String> nullableContainer)"
);
}

@Test
public void shouldNotAnnotateNonRequiredFieldsAsNullableWhileUseOptional() throws IOException {
SpringCodegen codegen = new SpringCodegen();
codegen.setLibrary(SPRING_BOOT);
codegen.setGenerateConstructorWithAllArgs(true);
codegen.setUseOptional(true);

Map<String, File> files = generateFiles(codegen, "src/test/resources/3_0/nullable-annotation.yaml");
var file = files.get("Item.java");

JavaFileAssert.assertThat(file)
.assertProperty("mandatoryName")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalDescription")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalOneWithDefault")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("nullableStr")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.fileContains(
"public Item(String mandatoryName, String optionalDescription," +
" String optionalOneWithDefault, String nullableStr"
);
}

@Test
public void shouldAnnotateNonRequiredFieldsAsNullableWhileNotUsingOpenApiNullableAndContainerDefaultToNullSet() throws IOException {
SpringCodegen codegen = new SpringCodegen();
codegen.setLibrary(SPRING_BOOT);
codegen.setGenerateConstructorWithAllArgs(true);
codegen.setOpenApiNullable(false);
codegen.setContainerDefaultToNull(true);

Map<String, File> files = generateFiles(codegen, "src/test/resources/3_0/nullable-annotation.yaml");
var file = files.get("Item.java");

JavaFileAssert.assertThat(file)
.assertProperty("mandatoryName")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalDescription")
.hasAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalOneWithDefault")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("nullableStr")
.hasAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("mandatoryContainer")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalContainer")
.hasAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("optionalContainerWithDefault")
.doesNotHaveAnnotation("Nullable");
JavaFileAssert.assertThat(file)
.assertProperty("nullableContainer")
.hasAnnotation("Nullable");

JavaFileAssert.assertThat(file)
.fileContains(
" List<String> mandatoryContainer," +
" @Nullable List<String> optionalContainer," +
" List<String> optionalContainerWithDefault," +
" @Nullable List<String> nullableContainer)"
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
openapi: 3.0.0
components:
schemas:
Item:
type: object
required:
- mandatoryName
- mandatoryContainer
properties:
mandatoryName:
type: string
optionalDescription:
type: string
optionalOneWithDefault:
type: string
default: "someDefaultValue"
nullableStr:
type: string
nullable: true
mandatoryContainer:
type: array
items:
type: string
optionalContainer:
type: array
items:
type: string
optionalContainerWithDefault:
type: array
items:
type: string
default: [ ]
nullableContainer:
type: array
items:
type: string
nullable: true
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import java.time.LocalDate;
import java.time.OffsetDateTime;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.lang.Nullable;
import org.openapitools.jackson.nullable.JsonNullable;
import java.time.OffsetDateTime;
import javax.validation.Valid;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonCreator;
import org.springframework.lang.Nullable;
import org.openapitools.jackson.nullable.JsonNullable;
import java.time.OffsetDateTime;
import javax.validation.Valid;
Expand All @@ -22,9 +23,9 @@
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.11.0-SNAPSHOT")
public class Category {

private Long id;
private @Nullable Long id;

private String name;
private @Nullable String name;

public Category id(Long id) {
this.id = id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonTypeName;
import org.springframework.lang.Nullable;
import org.openapitools.jackson.nullable.JsonNullable;
import java.time.OffsetDateTime;
import javax.validation.Valid;
Expand All @@ -24,11 +25,11 @@
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.11.0-SNAPSHOT")
public class ModelApiResponse {

private Integer code;
private @Nullable Integer code;

private String type;
private @Nullable String type;

private String message;
private @Nullable String message;

public ModelApiResponse code(Integer code) {
this.code = code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.fasterxml.jackson.annotation.JsonValue;
import java.time.OffsetDateTime;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.lang.Nullable;
import org.openapitools.jackson.nullable.JsonNullable;
import java.time.OffsetDateTime;
import javax.validation.Valid;
Expand All @@ -27,14 +28,14 @@
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.11.0-SNAPSHOT")
public class Order {

private Long id;
private @Nullable Long id;

private Long petId;
private @Nullable Long petId;

private Integer quantity;
private @Nullable Integer quantity;

@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private OffsetDateTime shipDate;
private @Nullable OffsetDateTime shipDate;

/**
* Order Status
Expand Down Expand Up @@ -73,7 +74,7 @@ public static StatusEnum fromValue(String value) {
}
}

private StatusEnum status;
private @Nullable StatusEnum status;

private Boolean complete = false;

Expand Down
Loading

0 comments on commit cba756f

Please sign in to comment.