From ada02a0959dcac6698469979e3b0b3ec16559bc7 Mon Sep 17 00:00:00 2001 From: Sharang Gupta Date: Tue, 6 Oct 2026 21:18:57 +0100 Subject: [PATCH] fix: honour JSpecify @Nullable on controller parameters For a controller method parameter, GenericParameterService only forwarded the annotations declared on the parameter's type arguments to swagger-core, never the ones declared on the parameter type itself. A type-use-only annotation such as the JSpecify @Nullable (the annotation Spring Framework 7 standardises on) was therefore dropped before schema extraction: Spring already marked the parameter as not required, but its schema stayed non-nullable, unlike the same parameter annotated with Spring's declaration targeted @Nullable. Flattened @ParameterObject fields were given both sets of annotations in #3300. Apply the same treatment to direct parameters by reusing annotationsFromAnnotatedType, made null-safe for the parameters whose AnnotatedType cannot be resolved. The regression app also covers a @PathVariable carrying the same annotation, which the #3358 guard keeps non-nullable. Fixes #3377 --- CHANGELOG.md | 4 + .../core/service/GenericParameterService.java | 12 ++- .../api/v30/app274/HelloController.java | 53 +++++++++++ .../api/v30/app274/SpringDocApp274Test.java | 42 +++++++++ .../api/v31/app274/HelloController.java | 53 +++++++++++ .../api/v31/app274/SpringDocApp274Test.java | 42 +++++++++ .../test/resources/results/3.0.1/app274.json | 87 ++++++++++++++++++ .../test/resources/results/3.1.0/app274.json | 91 +++++++++++++++++++ 8 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/SpringDocApp274Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/SpringDocApp274Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app274.json create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app274.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 48dffa887..eb2489a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The MCP dashboard no longer pre-fills the OAuth2 token endpoint, client id and client secret. The form shows hints instead, and warns when the token endpoint is not HTTPS +### Fixed + +- #3377 – JSpecify `@Nullable` on a controller method parameter is not reflected as nullable in the parameter schema + ## [3.1.1] - 2026-09-06 ### Security diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java index 2009a7573..9d9039b9d 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java @@ -449,14 +449,15 @@ private TypeAndTypeAnnotations resolveTypeAndTypeAnnotationsForParameter(MethodP } List typeAnnotations = Stream.concat( - Arrays.stream(annotationsFromAnnotatedTypeArguments(getParameterAnnotatedType(methodParameter))), + Arrays.stream(annotationsFromAnnotatedType(getParameterAnnotatedType(methodParameter))), Arrays.stream(methodParameter.getParameterType().getAnnotations())).toList(); return new TypeAndTypeAnnotations(type, typeAnnotations); } /** - * Resolves the {@link AnnotatedType} of a method parameter so that annotations declared on its - * generic type arguments (for example inside a {@link List} or an {@link Optional}) can be inspected. + * Resolves the {@link AnnotatedType} of a method parameter so that annotations declared on the + * type itself (for example a JSpecify {@code @Nullable}) or on its generic type arguments (for + * example inside a {@link List} or an {@link Optional}) can be inspected. * * @param methodParameter the method parameter * @return the annotated type, or {@code null} if it cannot be resolved @@ -481,10 +482,13 @@ private record TypeAndTypeAnnotations(Type type, List typeAnnotation * Collects annotations declared on the type itself and on each type argument of an * {@link AnnotatedParameterizedType}. * - * @param annotatedType the annotated type + * @param annotatedType the annotated type, possibly {@code null} * @return a new array, possibly empty */ private static Annotation[] annotationsFromAnnotatedType(AnnotatedType annotatedType) { + if (annotatedType == null) { + return new Annotation[0]; + } return Stream.concat( Arrays.stream(annotatedType.getAnnotations()), Arrays.stream(annotationsFromAnnotatedTypeArguments(annotatedType))).toArray(Annotation[]::new); diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/HelloController.java new file mode 100644 index 000000000..e26c134c4 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/HelloController.java @@ -0,0 +1,53 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2026 the original author or authors. + * * * * * + * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * you may not use this file except in compliance with the License. + * * * * * You may obtain a copy of the License at + * * * * * + * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * + * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ +package test.org.springdoc.api.v30.app274; + +import org.jspecify.annotations.Nullable; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Declares request parameters whose nullability is expressed with the JSpecify {@link Nullable} + * annotation, which only targets type uses, and a path variable carrying the same annotation, + * which must never be described as nullable. + * + * @author sharanggupta + */ +@RestController +public class HelloController { + + @GetMapping("/api/items") + String items(@RequestParam @Nullable String filter, @RequestParam @Nullable Integer limit) { + return null; + } + + @GetMapping("/api/items/{id}") + String item(@PathVariable @Nullable String id) { + return null; + } + +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/SpringDocApp274Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/SpringDocApp274Test.java new file mode 100644 index 000000000..a96e84b95 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/SpringDocApp274Test.java @@ -0,0 +1,42 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2026 the original author or authors. + * * * * * + * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * you may not use this file except in compliance with the License. + * * * * * You may obtain a copy of the License at + * * * * * + * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * + * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ +package test.org.springdoc.api.v30.app274; + +import test.org.springdoc.api.v30.AbstractSpringDocV30Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Regression: a JSpecify {@code @Nullable} declared on a controller parameter must make the + * generated parameter schema nullable, as it already does for a flattened + * {@code @ParameterObject} field. + * + * @author sharanggupta + */ +public class SpringDocApp274Test extends AbstractSpringDocV30Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/HelloController.java new file mode 100644 index 000000000..e495fcbf8 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/HelloController.java @@ -0,0 +1,53 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2026 the original author or authors. + * * * * * + * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * you may not use this file except in compliance with the License. + * * * * * You may obtain a copy of the License at + * * * * * + * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * + * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ +package test.org.springdoc.api.v31.app274; + +import org.jspecify.annotations.Nullable; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Declares request parameters whose nullability is expressed with the JSpecify {@link Nullable} + * annotation, which only targets type uses, and a path variable carrying the same annotation, + * which must never be described as nullable. + * + * @author sharanggupta + */ +@RestController +public class HelloController { + + @GetMapping("/api/items") + String items(@RequestParam @Nullable String filter, @RequestParam @Nullable Integer limit) { + return null; + } + + @GetMapping("/api/items/{id}") + String item(@PathVariable @Nullable String id) { + return null; + } + +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/SpringDocApp274Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/SpringDocApp274Test.java new file mode 100644 index 000000000..1d5d815c3 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/SpringDocApp274Test.java @@ -0,0 +1,42 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2026 the original author or authors. + * * * * * + * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * you may not use this file except in compliance with the License. + * * * * * You may obtain a copy of the License at + * * * * * + * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * + * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ +package test.org.springdoc.api.v31.app274; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Regression: a JSpecify {@code @Nullable} declared on a controller parameter must make the + * generated parameter schema nullable, as it already does for a flattened + * {@code @ParameterObject} field. + * + * @author sharanggupta + */ +public class SpringDocApp274Test extends AbstractSpringDocTest { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app274.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app274.json new file mode 100644 index 000000000..c8a2df17d --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app274.json @@ -0,0 +1,87 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/items": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "items", + "parameters": [ + { + "name": "filter", + "in": "query", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/items/{id}": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "item", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": {} +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app274.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app274.json new file mode 100644 index 000000000..e738fbd97 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app274.json @@ -0,0 +1,91 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/items": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "items", + "parameters": [ + { + "name": "filter", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/items/{id}": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "item", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": {} +}