From 7a714ffb6137f8ca2664db6ca3c84c3a28c307fa Mon Sep 17 00:00:00 2001 From: abhaydubey93 Date: Fri, 17 Jul 2026 15:16:00 +0000 Subject: [PATCH 1/2] [Java][Spring] Emit @Nullable on non-required fields with a default value under jspecify (#24294) Under jspecify, a non-required model field carrying a `default` value was generated without the `@Nullable` annotation. A non-required field is nullable regardless of its default, because the caller can still explicitly pass null. The JavaSpring `nullableAnnotation` partial gated the annotation behind `isNullable` in its `defaultValue` branch, suppressing @Nullable for the common case (non-required + default + openApiNullable=false). This scopes the fix to `useJspecify` so defaulted non-required fields are treated the same as non-defaulted ones, while leaving the non-jspecify path byte-for-byte unchanged. Adds a regression test (testJspecifyNullableWithDefaultValue_issue24294) with a minimal spec. Full SpringCodegenTest suite passes (290 tests, 0 failures). --- .../JavaSpring/nullableAnnotation.mustache | 2 +- .../java/spring/SpringCodegenTest.java | 26 ++++++++++++ .../resources/3_0/java/jspecify-default.yaml | 40 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/java/jspecify-default.yaml diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/nullableAnnotation.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/nullableAnnotation.mustache index a3ff1bad10e2..ff8e41aad967 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/nullableAnnotation.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/nullableAnnotation.mustache @@ -1 +1 @@ -{{#lambda.jSpecifyNullable}}{{^required}}{{^defaultValue}}{{^useOptional}}{{#openApiNullable}}{{^isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{^openApiNullable}}@Nullable {{/openApiNullable}}{{/useOptional}}{{/defaultValue}}{{#defaultValue}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/defaultValue}}{{/required}}{{/lambda.jSpecifyNullable}} \ No newline at end of file +{{#lambda.jSpecifyNullable}}{{^required}}{{^defaultValue}}{{^useOptional}}{{#openApiNullable}}{{^isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{^openApiNullable}}@Nullable {{/openApiNullable}}{{/useOptional}}{{/defaultValue}}{{#defaultValue}}{{#useJspecify}}{{^useOptional}}{{#openApiNullable}}{{^isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{^openApiNullable}}@Nullable {{/openApiNullable}}{{/useOptional}}{{/useJspecify}}{{^useJspecify}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/useJspecify}}{{/defaultValue}}{{/required}}{{/lambda.jSpecifyNullable}} \ No newline at end of file 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 136c0196e669..f09238f49b4b 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 @@ -7084,6 +7084,32 @@ public void testJspecify(String library, int springBootVersion, String fooApiFil } } + @Test + public void testJspecifyNullableWithDefaultValue_issue24294() throws IOException { + // A non-required field is nullable regardless of whether it declares a default value, + // because the caller can still explicitly pass null. The @Nullable (jspecify) annotation + // must therefore be emitted even when the property has a `default`. + final Map files = generateFromContract("src/test/resources/3_0/java/jspecify-default.yaml", + SPRING_BOOT, + Map.of(USE_JSPECIFY, true, + CONTAINER_DEFAULT_TO_NULL, true, + SpringCodegen.OPENAPI_NULLABLE, false, + USE_BEANVALIDATION, true, + INTERFACE_ONLY, false)); + + JavaFileAssert.assertThat(files.get("CobJob.java")) + .assertTypeAnnotations().doesImportAnnotation("org.jspecify.annotations.Nullable").toType() + .fileContains( + // non-required fields carrying a default value keep @Nullable + "private @Nullable Boolean force = false;", + "private @Nullable Integer retries = 3;", + "private @Nullable String label = \"none\";" + ) + // required field must NOT be annotated with @Nullable + .fileContains("private String name;") + .fileDoesNotContain("private @Nullable String name;"); + } + // ------------------------------------------------------------------------- // autoXSpringPaginated tests // ------------------------------------------------------------------------- diff --git a/modules/openapi-generator/src/test/resources/3_0/java/jspecify-default.yaml b/modules/openapi-generator/src/test/resources/3_0/java/jspecify-default.yaml new file mode 100644 index 000000000000..4f9e50f1ef47 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/java/jspecify-default.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.0 +info: + description: test jspecify @Nullable on non-required fields that carry a default value + title: jspecify-default + version: 1.0.0 +paths: + /job: + post: + operationId: createJob + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CobJob' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CobJob' +components: + schemas: + CobJob: + type: object + required: + - name + properties: + name: + type: string + force: + type: boolean + default: false + retries: + type: integer + format: int32 + default: 3 + label: + type: string + default: "none" From 2ff022f166ca998a955d725760172f24a4cce3f6 Mon Sep 17 00:00:00 2001 From: abhaydubey93 Date: Fri, 17 Jul 2026 17:18:40 +0000 Subject: [PATCH 2/2] [Java][Spring] Scope defaulted-@Nullable fix to model properties, not request params The previous change made every non-required field with a default value @Nullable under jspecify. Because nullableAnnotation.mustache is shared between POJO model fields and Spring controller parameters, this incorrectly annotated non-required query/header/cookie/path/body/form parameters that declare a default value. Those parameters are non-null at runtime: Spring's @RequestParam/@RequestHeader/ @CookieValue supply the configured defaultValue when the request value is absent, so @Nullable is a wrong nullness contract for them. Restrict the new behavior to model properties (identified by the absence of the CodegenParameter isXxxParam flags). Parameter rendering is unchanged from before this fix. Extends the regression test with a non-required query param carrying a default (must not be @Nullable) alongside one without a default (stays @Nullable). Full SpringCodegenTest suite passes (290 tests, 0 failures). --- .../JavaSpring/nullableAnnotation.mustache | 2 +- .../java/spring/SpringCodegenTest.java | 9 +++++++ .../resources/3_0/java/jspecify-default.yaml | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/nullableAnnotation.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/nullableAnnotation.mustache index ff8e41aad967..d63354b32249 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/nullableAnnotation.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/nullableAnnotation.mustache @@ -1 +1 @@ -{{#lambda.jSpecifyNullable}}{{^required}}{{^defaultValue}}{{^useOptional}}{{#openApiNullable}}{{^isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{^openApiNullable}}@Nullable {{/openApiNullable}}{{/useOptional}}{{/defaultValue}}{{#defaultValue}}{{#useJspecify}}{{^useOptional}}{{#openApiNullable}}{{^isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{^openApiNullable}}@Nullable {{/openApiNullable}}{{/useOptional}}{{/useJspecify}}{{^useJspecify}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/useJspecify}}{{/defaultValue}}{{/required}}{{/lambda.jSpecifyNullable}} \ No newline at end of file +{{#lambda.jSpecifyNullable}}{{^required}}{{^defaultValue}}{{^useOptional}}{{#openApiNullable}}{{^isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{^openApiNullable}}@Nullable {{/openApiNullable}}{{/useOptional}}{{/defaultValue}}{{#defaultValue}}{{! non-required + default: model properties are nullable (a caller may still pass null); request params keep Spring's non-null default binding }}{{#useJspecify}}{{^isQueryParam}}{{^isHeaderParam}}{{^isCookieParam}}{{^isPathParam}}{{^isBodyParam}}{{^isFormParam}}{{^useOptional}}{{#openApiNullable}}{{^isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{^openApiNullable}}@Nullable {{/openApiNullable}}{{/useOptional}}{{/isFormParam}}{{/isBodyParam}}{{/isPathParam}}{{/isCookieParam}}{{/isHeaderParam}}{{/isQueryParam}}{{#isQueryParam}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/isQueryParam}}{{#isHeaderParam}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/isHeaderParam}}{{#isCookieParam}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/isCookieParam}}{{#isPathParam}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/isPathParam}}{{#isBodyParam}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/isBodyParam}}{{#isFormParam}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/isFormParam}}{{/useJspecify}}{{^useJspecify}}{{^openApiNullable}}{{#isNullable}}@Nullable {{/isNullable}}{{/openApiNullable}}{{/useJspecify}}{{/defaultValue}}{{/required}}{{/lambda.jSpecifyNullable}} \ No newline at end of file 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 f09238f49b4b..fae37239fe0b 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 @@ -7108,6 +7108,15 @@ public void testJspecifyNullableWithDefaultValue_issue24294() throws IOException // required field must NOT be annotated with @Nullable .fileContains("private String name;") .fileDoesNotContain("private @Nullable String name;"); + + // A non-required request parameter carrying a default value must NOT be @Nullable: + // Spring's @RequestParam(defaultValue = ...) supplies a non-null value when the + // request value is absent, so annotating it @Nullable would be an incorrect contract. + // A non-required parameter without a default stays @Nullable. + JavaFileAssert.assertThat(files.get("JobApi.java")) + .assertMethod("listJobs") + .assertParameter("limit").assertParameterAnnotations().doesNotContainWithName("Nullable").toParameter().toMethod() + .assertParameter("filter").assertParameterAnnotations().containsWithName("Nullable"); } // ------------------------------------------------------------------------- diff --git a/modules/openapi-generator/src/test/resources/3_0/java/jspecify-default.yaml b/modules/openapi-generator/src/test/resources/3_0/java/jspecify-default.yaml index 4f9e50f1ef47..c565bb413800 100644 --- a/modules/openapi-generator/src/test/resources/3_0/java/jspecify-default.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/java/jspecify-default.yaml @@ -5,6 +5,33 @@ info: version: 1.0.0 paths: /job: + get: + operationId: listJobs + parameters: + # non-required query param WITH a default: Spring supplies the default when + # the request value is absent, so the generated param must NOT be @Nullable + - in: query + name: limit + required: false + schema: + type: integer + format: int32 + default: 20 + # non-required query param WITHOUT a default: nullable, keeps @Nullable + - in: query + name: filter + required: false + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CobJob' post: operationId: createJob requestBody: