From 3979ad55377059628bf4cd346a82e4c576a9a22e Mon Sep 17 00:00:00 2001 From: Mario Fiestelmann Date: Tue, 27 Oct 2026 11:51:00 +0100 Subject: [PATCH] [Java][jaxrs-spec] Annotate nullable @JsonCreator/@JsonbCreator parameters with JSpecify (fixes #25438) With useJspecify, pojo.mustache annotates fields, getters, setters and fluent setters of nullable properties, but not the parameters of the @JsonCreator and @JsonbCreator constructors. Those constructors take the required properties, so a property that is both required and nullable gets an unannotated parameter. Under the generated @NullMarked package that parameter means non-null, and callers passing null are reported by NullAway and other JSpecify checkers. Use the same pattern as the getter: nullable_var_annotations stashes the annotation and the jSpecifyDatatype lambda emits it in type-use position (e.g. java.math.@Nullable BigDecimal). --- .../resources/JavaJaxRS/spec/pojo.mustache | 4 +- .../jaxrs/JavaJAXRSSpecServerCodegenTest.java | 40 +++++++++++++++++++ .../model/RequiredAndNullable.java | 8 ++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/JavaJaxRS/spec/pojo.mustache b/modules/openapi-generator/src/main/resources/JavaJaxRS/spec/pojo.mustache index 48b3020c8c59..2af465cc3880 100644 --- a/modules/openapi-generator/src/main/resources/JavaJaxRS/spec/pojo.mustache +++ b/modules/openapi-generator/src/main/resources/JavaJaxRS/spec/pojo.mustache @@ -115,7 +115,7 @@ public {{>sealed}}class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#v @JsonCreator public {{classname}}( {{#requiredVars}} - @JsonProperty(required = {{required}}, value = "{{baseName}}") {{>beanValidatedType}} {{name}}{{^-last}},{{/-last}} + @JsonProperty(required = {{required}}, value = "{{baseName}}") {{#useJspecify}}{{>nullable_var_annotations}}{{/useJspecify}}{{#lambda.jSpecifyDatatype}}{{>beanValidatedType}}{{/lambda.jSpecifyDatatype}} {{name}}{{^-last}},{{/-last}} {{/requiredVars}} ) { {{#parent}} @@ -141,7 +141,7 @@ public {{>sealed}}class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#v @JsonbCreator public {{classname}}( {{#requiredVars}} - @JsonbProperty("{{baseName}}") {{>beanValidatedType}} {{name}}{{^-last}},{{/-last}} + @JsonbProperty("{{baseName}}") {{#useJspecify}}{{>nullable_var_annotations}}{{/useJspecify}}{{#lambda.jSpecifyDatatype}}{{>beanValidatedType}}{{/lambda.jSpecifyDatatype}} {{name}}{{^-last}},{{/-last}} {{/requiredVars}} ) { {{#parent}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/jaxrs/JavaJAXRSSpecServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/jaxrs/JavaJAXRSSpecServerCodegenTest.java index 690755198af7..bf8cd27a2ef2 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/jaxrs/JavaJAXRSSpecServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/jaxrs/JavaJAXRSSpecServerCodegenTest.java @@ -2690,6 +2690,14 @@ public void testUseJspecify() throws Exception { "public java.math.@Nullable BigDecimal getNumber() {"); assertFileNotContains(model, "public Date getDt() {"); + // required properties that are nullable are passed through the @JsonCreator constructor, + // so its parameters need the annotation too, or callers cannot pass null under @NullMarked + final Path requiredAndNullable = Paths.get(output.toPath() + "/src/gen/java/org/openapitools/model/RequiredAndNullable.java"); + assertFileContains(requiredAndNullable, + "@JsonProperty(required = true, value = \"str\") @Nullable String str", + "@JsonProperty(required = true, value = \"list\") @Nullable List _list", + "@JsonProperty(required = true, value = \"onlyRequired\") String onlyRequired"); + // @NullMarked is what makes the unannotated types mean non-null assertFileContains(Paths.get(output.toPath() + "/src/gen/java/org/openapitools/model/package-info.java"), "@org.jspecify.annotations.NullMarked"); @@ -2728,6 +2736,38 @@ public void testUseJspecifyDisabledByDefault() throws Exception { assertFileNotContains(Paths.get(output.toPath() + "/pom.xml"), "jspecify"); } + /** + * The JSON-B @JsonbCreator constructor takes the required properties as well, so nullable + * ones must be annotated there just like in the Jackson @JsonCreator constructor. + */ + @Test + public void testUseJspecifyWithJsonbCreator() throws Exception { + Map properties = new HashMap<>(); + properties.put(USE_JSPECIFY, true); + properties.put(CodegenConstants.SERIALIZATION_LIBRARY, SERIALIZATION_LIBRARY_JSONB); + properties.put(AbstractJavaJAXRSServerCodegen.USE_JAKARTA_EE, true); + properties.put(INTERFACE_ONLY, true); + + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("jaxrs-spec") + .setAdditionalProperties(properties) + .setValidateSpec(false) + .setInputSpec("src/test/resources/3_0/java/jspecify.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + final Path requiredAndNullable = Paths.get(output.toPath() + "/src/gen/java/org/openapitools/model/RequiredAndNullable.java"); + assertFileContains(requiredAndNullable, + "@JsonbCreator", + "@JsonbProperty(\"str\") @Nullable String str", + "@JsonbProperty(\"list\") @Nullable List _list", + "@JsonbProperty(\"onlyRequired\") String onlyRequired"); + } + /** * jaxrs-cxf-cdi extends this generator but uses its own template directory, which has no diff --git a/samples/server/petstore/jaxrs-spec-quarkus-jspecify/src/gen/java/org/openapitools/model/RequiredAndNullable.java b/samples/server/petstore/jaxrs-spec-quarkus-jspecify/src/gen/java/org/openapitools/model/RequiredAndNullable.java index 36c7193aad2d..8569ea8ca3bb 100644 --- a/samples/server/petstore/jaxrs-spec-quarkus-jspecify/src/gen/java/org/openapitools/model/RequiredAndNullable.java +++ b/samples/server/petstore/jaxrs-spec-quarkus-jspecify/src/gen/java/org/openapitools/model/RequiredAndNullable.java @@ -30,11 +30,11 @@ public RequiredAndNullable() { @JsonCreator public RequiredAndNullable( - @JsonProperty(required = true, value = "str") String str, - @JsonProperty(required = true, value = "file") File _file, - @JsonProperty(required = true, value = "color") String color, + @JsonProperty(required = true, value = "str") @Nullable String str, + @JsonProperty(required = true, value = "file") @Nullable File _file, + @JsonProperty(required = true, value = "color") @Nullable String color, @JsonProperty(required = true, value = "onlyRequired") String onlyRequired, - @JsonProperty(required = true, value = "list") List _list + @JsonProperty(required = true, value = "list") @Nullable List _list ) { this.str = str; this._file = _file;